detached e86dbd6

feat(web): summary doc tabs, license and release badges on the index, keyboard fixes (!34)

- feat(web): document tabs and main-content navigation on the summary
- fix(web): select the summary doc by exact file name first
- feat(web): show cached repository licenses and latest release badges
- fix(web): new activity pill expires after a minute on the index
- feat(web): small refresh icon instead of the new activity pill, expires after a minute
- style(web): CI mark alone on the index, state in the tooltip
- style(web): smaller badge text
- merge(web): combine summary docs and index badges
- fix(web): refresh the index row on release events
- fix(web): tell MIT-0 from MIT in license detection
- test(web): mock the activity timer in the a11y test
- test(web): summary golden with the labelled document card and no rail cursor
- chore(web): mark renderDoc as display-only for hexcheck
- style(web): link pills without underline, lighter border on hover
- fix(web): keep the row hover underline off the name pills
- fix(web): count ids and entry overhead in the path and license cache budget
commit
e86dbd6dcb13c995198e6c231b1771aab474a884
author
brice · · 2026-10-22 12:20 UTC
committer
ermite · · 2026-10-22 13:11 UTC
parent
6a915d6
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
4/4
refs
tag: v0.35.0
27 files · +1694 -728
M internal/adapters/web/ASSETS.md +1 -1
@@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime.
1919 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
2020 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
2121 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
22-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
22+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `refresh-cw`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `refresh-cw`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
2323 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
2424 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
2525 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
@@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime.
19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
21 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |21 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
22-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |22+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `refresh-cw`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `refresh-cw`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
23 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |23 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
24 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |24 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
25 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |25 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
M internal/adapters/web/components.templ +27 -9
@@ -41,11 +41,17 @@ templ HomeRowView(r HomeRow) {
4141 <div class="repo-main">
4242 <h2 class="repo-name">
4343 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
44+ if rel := r.Release; rel != nil {
45+ <a class="pill repo-release" href={ templ.SafeURL(rel.Href) } title={ "Latest release: " + rel.Tag }>{ rel.Tag }</a>
46+ }
4447 if r.Private {
4548 <span class="pill">private</span>
4649 }
50+ if lic := r.License; lic != nil {
51+ <a class="pill repo-license" href={ templ.SafeURL(lic.Href) } title={ "License: " + lic.ID }>{ lic.ID }</a>
52+ }
4753 if r.Fresh {
48- <span class="pill new">new activity</span>
54+ <span class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span>
4955 }
5056 </h2>
5157 if r.Desc != "" {
@@ -94,9 +100,9 @@ templ HomeRowView(r HomeRow) {
94100 }
95101 </dl>
96102 if r.CI != nil {
97- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
103+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Label + " · " + r.CI.Title }>
98104 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
99- <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span>
105+ <span class="sr">{ "CI: " + r.CI.Label }</span>
100106 </a>
101107 }
102108 </div>
@@ -153,12 +159,24 @@ templ Outline(headings []Heading) {
153159 }
154160
155161 // ReadmeCard is a README in a card whose header names the file.
156-templ ReadmeCard(r Readme) {
157- <section class="card readme" aria-labelledby="readme-h">
158- <h2 class="card-h" id="readme-h">
159- <span class="ico i-file" aria-hidden="true"></span>
160- { r.Name }
161- </h2>
162+templ ReadmeCard(r Readme, docs ...DocTab) {
163+ <section class="card readme" aria-label={ r.Name }>
164+ if len(docs) > 1 {
165+ <nav class="card-h doc-tabs" aria-label="Repository documents">
166+ for _, doc := range docs {
167+ <a href={ templ.SafeURL(doc.Href) }
168+ if doc.Current {
169+ aria-current="page"
170+ }
171+ >{ doc.Name }</a>
172+ }
173+ </nav>
174+ } else {
175+ <h2 class="card-h" id="readme-h">
176+ <span class="ico i-file" aria-hidden="true"></span>
177+ { r.Name }
178+ </h2>
179+ }
162180 @ReadmeView(r)
163181 </section>
164182 }
@@ -41,11 +41,17 @@ templ HomeRowView(r HomeRow) {
41 <div class="repo-main">41 <div class="repo-main">
42 <h2 class="repo-name">42 <h2 class="repo-name">
43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
44+ if rel := r.Release; rel != nil {
45+ <a class="pill repo-release" href={ templ.SafeURL(rel.Href) } title={ "Latest release: " + rel.Tag }>{ rel.Tag }</a>
46+ }
44 if r.Private {47 if r.Private {
45 <span class="pill">private</span>48 <span class="pill">private</span>
46 }49 }
50+ if lic := r.License; lic != nil {
51+ <a class="pill repo-license" href={ templ.SafeURL(lic.Href) } title={ "License: " + lic.ID }>{ lic.ID }</a>
52+ }
47 if r.Fresh {53 if r.Fresh {
48- <span class="pill new">new activity</span>54+ <span class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span>
49 }55 }
50 </h2>56 </h2>
51 if r.Desc != "" {57 if r.Desc != "" {
@@ -94,9 +100,9 @@ templ HomeRowView(r HomeRow) {
94 }100 }
95 </dl>101 </dl>
96 if r.CI != nil {102 if r.CI != nil {
97- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>103+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Label + " · " + r.CI.Title }>
98 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>104 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
99- <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span>105+ <span class="sr">{ "CI: " + r.CI.Label }</span>
100 </a>106 </a>
101 }107 }
102 </div>108 </div>
@@ -153,12 +159,24 @@ templ Outline(headings []Heading) {
153 }159 }
154 160
155 // ReadmeCard is a README in a card whose header names the file.161 // ReadmeCard is a README in a card whose header names the file.
156-templ ReadmeCard(r Readme) {162+templ ReadmeCard(r Readme, docs ...DocTab) {
157- <section class="card readme" aria-labelledby="readme-h">163+ <section class="card readme" aria-label={ r.Name }>
158- <h2 class="card-h" id="readme-h">164+ if len(docs) > 1 {
159- <span class="ico i-file" aria-hidden="true"></span>165+ <nav class="card-h doc-tabs" aria-label="Repository documents">
160- { r.Name }166+ for _, doc := range docs {
161- </h2>167+ <a href={ templ.SafeURL(doc.Href) }
168+ if doc.Current {
169+ aria-current="page"
170+ }
171+ >{ doc.Name }</a>
172+ }
173+ </nav>
174+ } else {
175+ <h2 class="card-h" id="readme-h">
176+ <span class="ico i-file" aria-hidden="true"></span>
177+ { r.Name }
178+ </h2>
179+ }
162 @ReadmeView(r)180 @ReadmeView(r)
163 </section>181 </section>
164 }182 }
M internal/adapters/web/components_templ.go +504 -344 generated file: collapsed
M internal/adapters/web/fragments.go +8 -0
@@ -135,6 +135,14 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
135135 name, fresh = ev.Issue.Repo.String(), true
136136 case domain.IssueCommented:
137137 name, fresh = ev.Repo.String(), true
138+ case domain.ReleasePublished:
139+ name = ev.Release.Repo.String()
140+ case domain.ReleaseUpdated:
141+ name = ev.Release.Repo.String()
142+ case domain.ReleaseDeleted:
143+ name = ev.Release.Repo.String()
144+ case domain.ReleaseUnpublished:
145+ name = ev.Release.Repo.String()
138146 case domain.RunUpdated:
139147 name = ev.Run.Repo.String() // the CI state of the row
140148 case domain.JobUpdated:
@@ -135,6 +135,14 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
135 name, fresh = ev.Issue.Repo.String(), true135 name, fresh = ev.Issue.Repo.String(), true
136 case domain.IssueCommented:136 case domain.IssueCommented:
137 name, fresh = ev.Repo.String(), true137 name, fresh = ev.Repo.String(), true
138+ case domain.ReleasePublished:
139+ name = ev.Release.Repo.String()
140+ case domain.ReleaseUpdated:
141+ name = ev.Release.Repo.String()
142+ case domain.ReleaseDeleted:
143+ name = ev.Release.Repo.String()
144+ case domain.ReleaseUnpublished:
145+ name = ev.Release.Repo.String()
138 case domain.RunUpdated:146 case domain.RunUpdated:
139 name = ev.Run.Repo.String() // the CI state of the row147 name = ev.Run.Repo.String() // the CI state of the row
140 case domain.JobUpdated:148 case domain.JobUpdated:
M internal/adapters/web/home.go +2 -0
@@ -162,6 +162,7 @@ func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.
162162 // the newest commits of its default branch and the state of its latest run.
163163 func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
164164 row := homeRow(r, now, cursor, SignedIn(ctx))
165+ row.Release = s.summaryRelease(ctx, r.Name, now)
165166 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
166167 row.NoCommits = r.PushedAt.IsZero()
167168 return row
@@ -169,6 +170,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time,
169170 var head string
170171 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
171172 if head != "" {
173+ row.License = s.repositoryLicense(ctx, r.Name, head)
172174 row.CI = s.homeCI(ctx, r, head)
173175 row.Stats = s.homeStats(ctx, r, head, now)
174176 }
@@ -162,6 +162,7 @@ func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.
162 // the newest commits of its default branch and the state of its latest run.162 // the newest commits of its default branch and the state of its latest run.
163 func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {163 func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
164 row := homeRow(r, now, cursor, SignedIn(ctx))164 row := homeRow(r, now, cursor, SignedIn(ctx))
165+ row.Release = s.summaryRelease(ctx, r.Name, now)
165 if r.PushedAt.IsZero() || r.DefaultBranch == "" {166 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
166 row.NoCommits = r.PushedAt.IsZero()167 row.NoCommits = r.PushedAt.IsZero()
167 return row168 return row
@@ -169,6 +170,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time,
169 var head string170 var head string
170 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)171 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
171 if head != "" {172 if head != "" {
173+ row.License = s.repositoryLicense(ctx, r.Name, head)
172 row.CI = s.homeCI(ctx, r, head)174 row.CI = s.homeCI(ctx, r, head)
173 row.Stats = s.homeStats(ctx, r, head, now)175 row.Stats = s.homeStats(ctx, r, head, now)
174 }176 }
M internal/adapters/web/home_test.go +81 -2
@@ -7,6 +7,7 @@ import (
77 "context"
88 "errors"
99 "fmt"
10+ "html"
1011 "net/http"
1112 "net/http/httptest"
1213 "strings"
@@ -50,6 +51,7 @@ func homeCommits(t testing.TB, n int) []domain.Commit {
5051 func homeBrowse(t *testing.T) *mocks.BrowseService {
5152 t.Helper()
5253 b := mocks.NewBrowseService(t)
54+ b.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
5355 b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
5456 Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
5557 return b
@@ -63,6 +65,7 @@ func TestHomeRowFigures(t *testing.T) {
6365 head := objectID(t, fmt.Sprintf("%040x", 2))
6466 now := time.Now()
6567 browse := mocks.NewBrowseService(t)
68+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
6669 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
6770 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
6871 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
@@ -212,8 +215,10 @@ func TestHomeRowCI(t *testing.T) {
212215 }
213216 require.NotNil(t, row.CI)
214217 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215- assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")
216- assert.Contains(t, out, `<span class="sr">CI: </span>`)
218+ assert.Contains(t, out, `<span class="sr">CI: `+tt.label+`</span>`)
219+ assert.NotContains(t, out, `class="ci-label"`)
220+ assert.Contains(t, out, `title="`+html.EscapeString(tt.label+" · "+row.CI.Title)+`"`)
221+ assert.Contains(t, out, `class="ck ck-`+row.CI.Kind+`" aria-hidden="true"`)
217222 })
218223 }
219224 }
@@ -393,3 +398,77 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
393398 assert.Empty(t, got)
394399 }
395400 }
401+
402+func TestHomeReleaseAndLicensePills(t *testing.T) {
403+ t.Parallel()
404+ for _, tc := range []struct {
405+ name string
406+ release domain.Release
407+ err error
408+ visible bool
409+ }{
410+ {name: "official release", release: domain.Release{Tag: "v1.2.0", PublishedAt: fixedNow}, visible: true},
411+ {name: "plain tag only", err: domain.ErrNotFound},
412+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true}},
413+ {name: "unpublished", release: domain.Release{Tag: "pending"}},
414+ } {
415+ t.Run(tc.name, func(t *testing.T) {
416+ t.Parallel()
417+ repo := publicRepo(t, "proj")
418+ repo.Visibility = domain.VisibilityPrivate
419+ svc := mocks.NewReleaseService(t)
420+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
421+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: mocks.NewBrowseService(t), Releases: svc})
422+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
423+ row.Private, row.Fresh = true, true
424+ row.License = &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}
425+ out := renderRow(t, row)
426+ assert.Contains(t, out, `class="pill repo-license" href="/proj/blob/main/LICENSE" title="License: MIT">MIT</a>`)
427+ assert.Regexp(t, `(?s)>private</span>.*repo-license.*class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span></h2>`, out)
428+ if tc.visible {
429+ assert.Regexp(t, `(?s)>proj</a>.*class="pill repo-release".*>v1.2.0</a>.*>private</span>`, out)
430+ assert.Contains(t, out, `href="/proj/releases/tag/v1.2.0"`)
431+ } else {
432+ assert.NotContains(t, out, `repo-release`)
433+ }
434+ })
435+ }
436+ out := renderRow(t, HomeRow{Name: "bare"})
437+ assert.NotContains(t, out, `repo-license`)
438+ assert.NotContains(t, out, `repo-release`)
439+}
440+
441+func TestReleaseEventsRefreshTheHomeRow(t *testing.T) {
442+ t.Parallel()
443+ repo := publicRepo(t, "proj")
444+ rel := domain.Release{Repo: repo.Name, Tag: "v2", PublishedAt: fixedNow}
445+ for _, tc := range []struct {
446+ event domain.Event
447+ latest domain.Release
448+ err error
449+ }{
450+ {domain.ReleasePublished{Release: rel}, rel, nil},
451+ {domain.ReleaseUpdated{Release: rel}, domain.Release{Tag: "v1", PublishedAt: fixedNow}, nil},
452+ {domain.ReleaseDeleted{Release: rel}, domain.Release{}, domain.ErrNotFound},
453+ {domain.ReleaseUnpublished{Release: rel}, domain.Release{}, domain.ErrNotFound},
454+ } {
455+ t.Run(tc.event.EventKind(), func(t *testing.T) {
456+ t.Parallel()
457+ releases := mocks.NewReleaseService(t)
458+ releases.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.latest, tc.err).Once()
459+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t), Releases: releases})
460+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: tc.event})
461+ require.NoError(t, err)
462+ require.Len(t, got, 1)
463+ assert.Equal(t, "home-list", got[0].event)
464+ assert.Contains(t, got[0].html, `id="repo-proj"`)
465+ assert.NotContains(t, got[0].html, `data-activity`)
466+ assert.NotContains(t, got[0].html, `new activity`)
467+ if tc.err != nil {
468+ assert.NotContains(t, got[0].html, `repo-release`)
469+ } else {
470+ assert.Contains(t, got[0].html, `href="/proj/releases/tag/`+tc.latest.Tag+`"`)
471+ }
472+ })
473+ }
474+}
@@ -7,6 +7,7 @@ import (
7 "context"7 "context"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10+ "html"
10 "net/http"11 "net/http"
11 "net/http/httptest"12 "net/http/httptest"
12 "strings"13 "strings"
@@ -50,6 +51,7 @@ func homeCommits(t testing.TB, n int) []domain.Commit {
50 func homeBrowse(t *testing.T) *mocks.BrowseService {51 func homeBrowse(t *testing.T) *mocks.BrowseService {
51 t.Helper()52 t.Helper()
52 b := mocks.NewBrowseService(t)53 b := mocks.NewBrowseService(t)
54+ b.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
53 b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).55 b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
54 Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()56 Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
55 return b57 return b
@@ -63,6 +65,7 @@ func TestHomeRowFigures(t *testing.T) {
63 head := objectID(t, fmt.Sprintf("%040x", 2))65 head := objectID(t, fmt.Sprintf("%040x", 2))
64 now := time.Now()66 now := time.Now()
65 browse := mocks.NewBrowseService(t)67 browse := mocks.NewBrowseService(t)
68+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
66 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)69 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
67 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{70 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
68 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},71 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
@@ -212,8 +215,10 @@ func TestHomeRowCI(t *testing.T) {
212 }215 }
213 require.NotNil(t, row.CI)216 require.NotNil(t, row.CI)
214 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)217 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215- assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")218+ assert.Contains(t, out, `<span class="sr">CI: `+tt.label+`</span>`)
216- assert.Contains(t, out, `<span class="sr">CI: </span>`)219+ assert.NotContains(t, out, `class="ci-label"`)
220+ assert.Contains(t, out, `title="`+html.EscapeString(tt.label+" · "+row.CI.Title)+`"`)
221+ assert.Contains(t, out, `class="ck ck-`+row.CI.Kind+`" aria-hidden="true"`)
217 })222 })
218 }223 }
219 }224 }
@@ -393,3 +398,77 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
393 assert.Empty(t, got)398 assert.Empty(t, got)
394 }399 }
395 }400 }
401+
402+func TestHomeReleaseAndLicensePills(t *testing.T) {
403+ t.Parallel()
404+ for _, tc := range []struct {
405+ name string
406+ release domain.Release
407+ err error
408+ visible bool
409+ }{
410+ {name: "official release", release: domain.Release{Tag: "v1.2.0", PublishedAt: fixedNow}, visible: true},
411+ {name: "plain tag only", err: domain.ErrNotFound},
412+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true}},
413+ {name: "unpublished", release: domain.Release{Tag: "pending"}},
414+ } {
415+ t.Run(tc.name, func(t *testing.T) {
416+ t.Parallel()
417+ repo := publicRepo(t, "proj")
418+ repo.Visibility = domain.VisibilityPrivate
419+ svc := mocks.NewReleaseService(t)
420+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
421+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: mocks.NewBrowseService(t), Releases: svc})
422+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
423+ row.Private, row.Fresh = true, true
424+ row.License = &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}
425+ out := renderRow(t, row)
426+ assert.Contains(t, out, `class="pill repo-license" href="/proj/blob/main/LICENSE" title="License: MIT">MIT</a>`)
427+ assert.Regexp(t, `(?s)>private</span>.*repo-license.*class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span></h2>`, out)
428+ if tc.visible {
429+ assert.Regexp(t, `(?s)>proj</a>.*class="pill repo-release".*>v1.2.0</a>.*>private</span>`, out)
430+ assert.Contains(t, out, `href="/proj/releases/tag/v1.2.0"`)
431+ } else {
432+ assert.NotContains(t, out, `repo-release`)
433+ }
434+ })
435+ }
436+ out := renderRow(t, HomeRow{Name: "bare"})
437+ assert.NotContains(t, out, `repo-license`)
438+ assert.NotContains(t, out, `repo-release`)
439+}
440+
441+func TestReleaseEventsRefreshTheHomeRow(t *testing.T) {
442+ t.Parallel()
443+ repo := publicRepo(t, "proj")
444+ rel := domain.Release{Repo: repo.Name, Tag: "v2", PublishedAt: fixedNow}
445+ for _, tc := range []struct {
446+ event domain.Event
447+ latest domain.Release
448+ err error
449+ }{
450+ {domain.ReleasePublished{Release: rel}, rel, nil},
451+ {domain.ReleaseUpdated{Release: rel}, domain.Release{Tag: "v1", PublishedAt: fixedNow}, nil},
452+ {domain.ReleaseDeleted{Release: rel}, domain.Release{}, domain.ErrNotFound},
453+ {domain.ReleaseUnpublished{Release: rel}, domain.Release{}, domain.ErrNotFound},
454+ } {
455+ t.Run(tc.event.EventKind(), func(t *testing.T) {
456+ t.Parallel()
457+ releases := mocks.NewReleaseService(t)
458+ releases.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.latest, tc.err).Once()
459+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t), Releases: releases})
460+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: tc.event})
461+ require.NoError(t, err)
462+ require.Len(t, got, 1)
463+ assert.Equal(t, "home-list", got[0].event)
464+ assert.Contains(t, got[0].html, `id="repo-proj"`)
465+ assert.NotContains(t, got[0].html, `data-activity`)
466+ assert.NotContains(t, got[0].html, `new activity`)
467+ if tc.err != nil {
468+ assert.NotContains(t, got[0].html, `repo-release`)
469+ } else {
470+ assert.Contains(t, got[0].html, `href="/proj/releases/tag/`+tc.latest.Tag+`"`)
471+ }
472+ })
473+ }
474+}
M internal/adapters/web/jstest/a11y.test.mjs +2 -1
@@ -339,7 +339,8 @@ test('the preference is read from storage when the page loads', async () => {
339339 });
340340
341341
342-test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
342+test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => {
343+ t.mock.timers.enable({ apis: ['setTimeout'] });
343344 for (const [sort, desc, activity, moves] of [
344345 ['activity', 'true', true, true],
345346 ['activity', 'false', true, false],
@@ -339,7 +339,8 @@ test('the preference is read from storage when the page loads', async () => {
339 });339 });
340 340
341 341
342-test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {342+test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => {
343+ t.mock.timers.enable({ apis: ['setTimeout'] });
343 for (const [sort, desc, activity, moves] of [344 for (const [sort, desc, activity, moves] of [
344 ['activity', 'true', true, true],345 ['activity', 'true', true, true],
345 ['activity', 'false', true, false],346 ['activity', 'false', true, false],
M internal/adapters/web/jstest/live.test.mjs +49 -0
@@ -79,3 +79,52 @@ test('replacement stream URL carries the last connection id, including id-only e
7979 assert.equal(url.searchParams.get('topic'), 'repo:demo');
8080 } finally { globalThis.location = saved; }
8181 });
82+
83+test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => {
84+ const { live } = setup(t);
85+ let current = null, next = null;
86+ let prepends = 0;
87+ const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) {
88+ prepends++;
89+ current = el;
90+ el.parentElement = this;
91+ } };
92+ const fresh = () => {
93+ const attrs = new Set(['data-activity']);
94+ const pill = { removed: false, remove() { this.removed = true; } };
95+ return { id: 'repo-demo', pill, parentElement: rows,
96+ querySelectorAll: () => [],
97+ hasAttribute: (name) => attrs.has(name),
98+ removeAttribute: (name) => attrs.delete(name),
99+ querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null,
100+ replaceWith(el) { current = el; el.parentElement = rows; },
101+ };
102+ };
103+ document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } });
104+ document.getElementById = (id) => id === 'repo-rows' ? rows : current;
105+ document.querySelectorAll = () => [];
106+
107+ next = fresh();
108+ live.swap('first');
109+ const first = current;
110+ assert.equal(prepends, 1, 'a newly added repository is prepended');
111+ t.mock.timers.tick(30000);
112+ next = fresh();
113+ live.swap('second');
114+ const second = current;
115+ assert.equal(prepends, 2, 'later activity reorders the existing row');
116+ t.mock.timers.tick(30000);
117+ assert.equal(first.pill.removed, true);
118+ assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge');
119+ t.mock.timers.tick(29999);
120+ assert.equal(second.pill.removed, false);
121+ t.mock.timers.tick(1);
122+ assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree');
123+ assert.equal(second.hasAttribute('data-activity'), false);
124+ next = fresh();
125+ live.swap('third');
126+ assert.equal(prepends, 3, 'activity still reorders after the previous badge expired');
127+ assert.equal(current.pill.removed, false);
128+ t.mock.timers.tick(60000);
129+ assert.equal(current.pill.removed, true);
130+});
@@ -79,3 +79,52 @@ test('replacement stream URL carries the last connection id, including id-only e
79 assert.equal(url.searchParams.get('topic'), 'repo:demo');79 assert.equal(url.searchParams.get('topic'), 'repo:demo');
80 } finally { globalThis.location = saved; }80 } finally { globalThis.location = saved; }
81 });81 });
82+
83+test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => {
84+ const { live } = setup(t);
85+ let current = null, next = null;
86+ let prepends = 0;
87+ const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) {
88+ prepends++;
89+ current = el;
90+ el.parentElement = this;
91+ } };
92+ const fresh = () => {
93+ const attrs = new Set(['data-activity']);
94+ const pill = { removed: false, remove() { this.removed = true; } };
95+ return { id: 'repo-demo', pill, parentElement: rows,
96+ querySelectorAll: () => [],
97+ hasAttribute: (name) => attrs.has(name),
98+ removeAttribute: (name) => attrs.delete(name),
99+ querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null,
100+ replaceWith(el) { current = el; el.parentElement = rows; },
101+ };
102+ };
103+ document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } });
104+ document.getElementById = (id) => id === 'repo-rows' ? rows : current;
105+ document.querySelectorAll = () => [];
106+
107+ next = fresh();
108+ live.swap('first');
109+ const first = current;
110+ assert.equal(prepends, 1, 'a newly added repository is prepended');
111+ t.mock.timers.tick(30000);
112+ next = fresh();
113+ live.swap('second');
114+ const second = current;
115+ assert.equal(prepends, 2, 'later activity reorders the existing row');
116+ t.mock.timers.tick(30000);
117+ assert.equal(first.pill.removed, true);
118+ assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge');
119+ t.mock.timers.tick(29999);
120+ assert.equal(second.pill.removed, false);
121+ t.mock.timers.tick(1);
122+ assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree');
123+ assert.equal(second.hasAttribute('data-activity'), false);
124+ next = fresh();
125+ live.swap('third');
126+ assert.equal(prepends, 3, 'activity still reorders after the previous badge expired');
127+ assert.equal(current.pill.removed, false);
128+ t.mock.timers.tick(60000);
129+ assert.equal(current.pill.removed, true);
130+});
M internal/adapters/web/jstest/rail.test.mjs +20 -0
@@ -62,3 +62,23 @@ test('h outside the panel leaves the focus alone; j / k outside it are not taken
6262 assert.equal(active, main);
6363 assert.equal(R.step(1), false, 'the row cursor keeps j / k');
6464 });
65+
66+test('j / k cross section and recent-commit table boundaries in document order', () => {
67+ const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } }));
68+ for (const e of controls) e.focus = () => { active = e; };
69+ rail.append(
70+ el('section', {}, controls[0]),
71+ el('section', {}, controls[1]),
72+ el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))),
73+ el('section', {}, controls[4]),
74+ el('nav', {}, controls[5]),
75+ );
76+ active = close;
77+ for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); }
78+ R.step(1);
79+ assert.equal(active, controls.at(-1));
80+ for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); }
81+ R.leave();
82+ assert.equal(active, main);
83+ assert.equal(R.step(1), false);
84+});
@@ -62,3 +62,23 @@ test('h outside the panel leaves the focus alone; j / k outside it are not taken
62 assert.equal(active, main);62 assert.equal(active, main);
63 assert.equal(R.step(1), false, 'the row cursor keeps j / k');63 assert.equal(R.step(1), false, 'the row cursor keeps j / k');
64 });64 });
65+
66+test('j / k cross section and recent-commit table boundaries in document order', () => {
67+ const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } }));
68+ for (const e of controls) e.focus = () => { active = e; };
69+ rail.append(
70+ el('section', {}, controls[0]),
71+ el('section', {}, controls[1]),
72+ el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))),
73+ el('section', {}, controls[4]),
74+ el('nav', {}, controls[5]),
75+ );
76+ active = close;
77+ for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); }
78+ R.step(1);
79+ assert.equal(active, controls.at(-1));
80+ for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); }
81+ R.leave();
82+ assert.equal(active, main);
83+ assert.equal(R.step(1), false);
84+});
A internal/adapters/web/jstest/summary-navigation.test.mjs +47 -0
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import assert from 'node:assert/strict';
4+import { readFileSync } from 'node:fs';
5+import { test } from 'node:test';
6+import { runInNewContext } from 'node:vm';
7+import { el, installDom } from './dom.mjs';
8+
9+const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
10+
11+test('main navigation ignores rail rows, but still drives main tables', () => {
12+ const doc = installDom();
13+ const main = el('main', { id: 'main' });
14+ const recent = el('tr');
15+ const rail = el('aside', { cls: 'rail' }, el('tbody', { attrs: { 'data-rows': '' } }, recent));
16+ doc.body.append(main, rail);
17+ const scrolls = [];
18+ main.scrollBy = (_, y) => scrolls.push(['main-by', y]);
19+ main.scrollTo = (_, y) => scrolls.push(['main-to', y]);
20+ const context = {
21+ window: { ermiteRail: { step: () => false } },
22+ $: (s) => doc.querySelector(s),
23+ $$: (s) => {
24+ const child = s.startsWith('[data-rows] > ') ? s.slice('[data-rows] > '.length) : null;
25+ if (child) return [...doc.querySelectorAll('[data-rows]')].flatMap((table) => table.children.filter((row) => row.matches(child)));
26+ return [...doc.querySelectorAll(s)];
27+ },
28+ body: () => doc.body,
29+ flash() {}, setTimeout() {},
30+ scrollBy: (_, y) => scrolls.push(['page-by', y]),
31+ scrollTo: (_, y) => scrolls.push(['page-to', y]),
32+ };
33+ runInNewContext(source.slice(source.indexOf(' const visibleRows ='), source.indexOf(' window.ermite = ermite;')) + '\nwindow.ermite = ermite;', context);
34+ const E = context.window.ermite;
35+ E.move(1); E.move(-1); E.edge(false); E.edge(true);
36+ assert.deepEqual(scrolls, [['page-by', 180], ['main-by', 180], ['page-by', -180], ['main-by', -180], ['page-to', 0], ['main-to', 0], ['page-to', 1e9], ['main-to', 1e9]]);
37+ assert.equal(recent.hasAttribute('data-cursor'), false);
38+ const first = el('tr');
39+ const last = el('tr');
40+ main.append(el('tbody', { attrs: { 'data-rows': '' } }, first, last));
41+ E.move(1);
42+ assert.equal(first.hasAttribute('data-cursor'), true);
43+ E.edge(true);
44+ assert.equal(last.hasAttribute('data-cursor'), true);
45+ assert.equal(first.hasAttribute('data-cursor'), false);
46+ assert.equal(recent.hasAttribute('data-cursor'), false);
47+});
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import assert from 'node:assert/strict';
4+import { readFileSync } from 'node:fs';
5+import { test } from 'node:test';
6+import { runInNewContext } from 'node:vm';
7+import { el, installDom } from './dom.mjs';
8+
9+const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
10+
11+test('main navigation ignores rail rows, but still drives main tables', () => {
12+ const doc = installDom();
13+ const main = el('main', { id: 'main' });
14+ const recent = el('tr');
15+ const rail = el('aside', { cls: 'rail' }, el('tbody', { attrs: { 'data-rows': '' } }, recent));
16+ doc.body.append(main, rail);
17+ const scrolls = [];
18+ main.scrollBy = (_, y) => scrolls.push(['main-by', y]);
19+ main.scrollTo = (_, y) => scrolls.push(['main-to', y]);
20+ const context = {
21+ window: { ermiteRail: { step: () => false } },
22+ $: (s) => doc.querySelector(s),
23+ $$: (s) => {
24+ const child = s.startsWith('[data-rows] > ') ? s.slice('[data-rows] > '.length) : null;
25+ if (child) return [...doc.querySelectorAll('[data-rows]')].flatMap((table) => table.children.filter((row) => row.matches(child)));
26+ return [...doc.querySelectorAll(s)];
27+ },
28+ body: () => doc.body,
29+ flash() {}, setTimeout() {},
30+ scrollBy: (_, y) => scrolls.push(['page-by', y]),
31+ scrollTo: (_, y) => scrolls.push(['page-to', y]),
32+ };
33+ runInNewContext(source.slice(source.indexOf(' const visibleRows ='), source.indexOf(' window.ermite = ermite;')) + '\nwindow.ermite = ermite;', context);
34+ const E = context.window.ermite;
35+ E.move(1); E.move(-1); E.edge(false); E.edge(true);
36+ assert.deepEqual(scrolls, [['page-by', 180], ['main-by', 180], ['page-by', -180], ['main-by', -180], ['page-to', 0], ['main-to', 0], ['page-to', 1e9], ['main-to', 1e9]]);
37+ assert.equal(recent.hasAttribute('data-cursor'), false);
38+ const first = el('tr');
39+ const last = el('tr');
40+ main.append(el('tbody', { attrs: { 'data-rows': '' } }, first, last));
41+ E.move(1);
42+ assert.equal(first.hasAttribute('data-cursor'), true);
43+ E.edge(true);
44+ assert.equal(last.hasAttribute('data-cursor'), true);
45+ assert.equal(first.hasAttribute('data-cursor'), false);
46+ assert.equal(recent.hasAttribute('data-cursor'), false);
47+});
A internal/adapters/web/license.go +115 -0
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "strings"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// LicenseBadge names the root license file and its display identifier.
16+type LicenseBadge struct {
17+ ID, Href string
18+}
19+
20+var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"}
21+
22+// repositoryLicense caches both absence and detection by immutable head. The
23+// caller has already authorized repository browsing. Failures are not cached.
24+// A separate instance of the bounded immutable-result cache coalesces misses.
25+func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge {
26+ key := repo.String() + "\x00" + head
27+ body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) {
28+ badge, err := s.detectLicense(ctx, repo, head)
29+ if err != nil {
30+ return nil, err
31+ }
32+ return json.Marshal(badge)
33+ })
34+ if err != nil {
35+ log := zerowrap.FromCtx(ctx)
36+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license")
37+ return nil
38+ }
39+ var badge *LicenseBadge
40+ if err := json.Unmarshal(body, &badge); err != nil {
41+ return nil
42+ }
43+ return badge
44+}
45+
46+//hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only
47+func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) {
48+ tree, err := s.getTree(ctx, repo, head, "", false)
49+ if err != nil {
50+ return nil, err
51+ }
52+ for _, name := range licenseNames {
53+ for _, entry := range tree.Entries {
54+ if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) {
55+ continue
56+ }
57+ body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10)
58+ if err != nil {
59+ return nil, err
60+ }
61+ id := "Other"
62+ if !binary {
63+ id = licenseID(string(body))
64+ }
65+ return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil
66+ }
67+ }
68+ return nil, nil
69+}
70+
71+// licenseID deliberately recognizes titles and characteristic grants, not
72+// incidental mentions of other licenses in their full text.
73+func licenseID(body string) string {
74+ for _, line := range strings.Split(body, "\n") {
75+ if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 {
76+ id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):])
77+ id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/"))
78+ if id != "" && len(id) <= 160 {
79+ return id
80+ }
81+ }
82+ }
83+ text := strings.ToLower(strings.Join(strings.Fields(body), " "))
84+ title := text[:min(len(text), 600)]
85+ switch {
86+ case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"):
87+ return "AGPL-3.0"
88+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"):
89+ return "LGPL-3.0"
90+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"):
91+ return "LGPL-2.1"
92+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"):
93+ return "GPL-3.0"
94+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"):
95+ return "GPL-2.0"
96+ case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"):
97+ return "Apache-2.0"
98+ case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"):
99+ return "MPL-2.0"
100+ case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""):
101+ if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") {
102+ return "MIT"
103+ }
104+ return "MIT-0"
105+ case strings.Contains(text, "all advertising materials"):
106+ return "Other"
107+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"):
108+ return "BSD-3-Clause"
109+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"):
110+ return "BSD-2-Clause"
111+ case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"):
112+ return "ISC"
113+ }
114+ return "Other"
115+}
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "strings"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// LicenseBadge names the root license file and its display identifier.
16+type LicenseBadge struct {
17+ ID, Href string
18+}
19+
20+var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"}
21+
22+// repositoryLicense caches both absence and detection by immutable head. The
23+// caller has already authorized repository browsing. Failures are not cached.
24+// A separate instance of the bounded immutable-result cache coalesces misses.
25+func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge {
26+ key := repo.String() + "\x00" + head
27+ body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) {
28+ badge, err := s.detectLicense(ctx, repo, head)
29+ if err != nil {
30+ return nil, err
31+ }
32+ return json.Marshal(badge)
33+ })
34+ if err != nil {
35+ log := zerowrap.FromCtx(ctx)
36+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license")
37+ return nil
38+ }
39+ var badge *LicenseBadge
40+ if err := json.Unmarshal(body, &badge); err != nil {
41+ return nil
42+ }
43+ return badge
44+}
45+
46+//hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only
47+func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) {
48+ tree, err := s.getTree(ctx, repo, head, "", false)
49+ if err != nil {
50+ return nil, err
51+ }
52+ for _, name := range licenseNames {
53+ for _, entry := range tree.Entries {
54+ if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) {
55+ continue
56+ }
57+ body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10)
58+ if err != nil {
59+ return nil, err
60+ }
61+ id := "Other"
62+ if !binary {
63+ id = licenseID(string(body))
64+ }
65+ return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil
66+ }
67+ }
68+ return nil, nil
69+}
70+
71+// licenseID deliberately recognizes titles and characteristic grants, not
72+// incidental mentions of other licenses in their full text.
73+func licenseID(body string) string {
74+ for _, line := range strings.Split(body, "\n") {
75+ if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 {
76+ id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):])
77+ id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/"))
78+ if id != "" && len(id) <= 160 {
79+ return id
80+ }
81+ }
82+ }
83+ text := strings.ToLower(strings.Join(strings.Fields(body), " "))
84+ title := text[:min(len(text), 600)]
85+ switch {
86+ case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"):
87+ return "AGPL-3.0"
88+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"):
89+ return "LGPL-3.0"
90+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"):
91+ return "LGPL-2.1"
92+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"):
93+ return "GPL-3.0"
94+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"):
95+ return "GPL-2.0"
96+ case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"):
97+ return "Apache-2.0"
98+ case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"):
99+ return "MPL-2.0"
100+ case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""):
101+ if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") {
102+ return "MIT"
103+ }
104+ return "MIT-0"
105+ case strings.Contains(text, "all advertising materials"):
106+ return "Other"
107+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"):
108+ return "BSD-3-Clause"
109+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"):
110+ return "BSD-2-Clause"
111+ case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"):
112+ return "ISC"
113+ }
114+ return "Other"
115+}
A internal/adapters/web/license_test.go +188 -0
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func TestLicenseID(t *testing.T) {
22+ t.Parallel()
23+ for _, tc := range []struct{ text, want string }{
24+ {"// SPDX-License-Identifier: MIT", "MIT"},
25+ {"<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->", "Apache-2.0 OR MIT"},
26+ {"/* SPDX-License-Identifier: GPL-3.0-or-later */", "GPL-3.0-or-later"},
27+ {"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007", "AGPL-3.0"},
28+ {"GNU GENERAL PUBLIC LICENSE Version 3" + strings.Repeat(" padding", 100) + "GNU Affero General Public License version 3", "GPL-3.0"},
29+ {"GNU GENERAL PUBLIC LICENSE Version 2", "GPL-2.0"},
30+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 2.1", "LGPL-2.1"},
31+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 3", "LGPL-3.0"},
32+ {"Apache License\nVersion 2.0, January 2004", "Apache-2.0"},
33+ {"Mozilla Public License Version 2.0", "MPL-2.0"},
34+ {"Permission is hereby granted, free of charge\nTHE SOFTWARE IS PROVIDED \"AS IS\"", "MIT-0"},
35+ {"Redistribution and use in source and binary forms ... neither the name", "BSD-3-Clause"},
36+ {"Redistribution and use in source and binary forms ... this list of conditions", "BSD-2-Clause"},
37+ {"Redistribution and use in source and binary forms ... all advertising materials ... neither the name", "Other"},
38+ {"Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee", "ISC"},
39+ {"Custom license mentioning MIT or Apache-2.0", "Other"},
40+ {"", "Other"},
41+ } {
42+ t.Run(tc.want+tc.text[:min(20, len(tc.text))], func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
43+ }
44+}
45+
46+func TestRepositoryLicenseCache(t *testing.T) {
47+ t.Parallel()
48+ for _, filename := range append(append([]string{}, licenseNames...), "LiCeNsE.MD", "missing", "directory") {
49+ t.Run(filename, func(t *testing.T) {
50+ t.Parallel()
51+ browse := mocks.NewBrowseService(t)
52+ repo := repoName(t, "proj")
53+ head := strings.Repeat("a", 40)
54+ entry := domain.TreeEntry{Name: filename, Path: filename, Kind: domain.EntryFile}
55+ if filename == "directory" {
56+ entry.Name, entry.Kind = "LICENSE", domain.EntryDirectory
57+ }
58+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{entry}}, nil).Once()
59+ found := filename != "missing" && filename != "directory"
60+ if found {
61+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("SPDX-License-Identifier: MIT")), nil).Once()
62+ }
63+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
64+ for range 2 {
65+ badge := srv.repositoryLicense(t.Context(), repo, head)
66+ if !found {
67+ assert.Nil(t, badge)
68+ continue
69+ }
70+ require.NotNil(t, badge)
71+ assert.Equal(t, "MIT", badge.ID)
72+ assert.Equal(t, "/proj/blob/"+head+"/"+filename, badge.Href)
73+ }
74+ })
75+ }
76+}
77+
78+func TestRepositoryLicenseRetriesErrorsAndNewHeads(t *testing.T) {
79+ t.Parallel()
80+ browse := mocks.NewBrowseService(t)
81+ repo := repoName(t, "proj")
82+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, errors.New("unavailable")).Once()
83+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Twice()
84+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
85+ for _, head := range []string{strings.Repeat("a", 40), strings.Repeat("a", 40), strings.Repeat("b", 40)} {
86+ assert.Nil(t, srv.repositoryLicense(context.Background(), repo, head))
87+ }
88+}
89+
90+func TestSummaryLicense(t *testing.T) {
91+ t.Parallel()
92+ assert.NotContains(t, renderRail(t, SummaryPage{}), "<dt>license</dt>")
93+ out := renderRail(t, SummaryPage{License: &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}})
94+ assert.Contains(t, out, `<dt>license</dt><dd><a href="/proj/blob/main/LICENSE">MIT</a></dd>`)
95+}
96+
97+func TestHomeLicenseFromDefaultHead(t *testing.T) {
98+ t.Parallel()
99+ for _, binary := range []bool{false, true} {
100+ name := "unknown text"
101+ if binary {
102+ name = "binary"
103+ }
104+ t.Run(name, func(t *testing.T) {
105+ t.Parallel()
106+ browse := mocks.NewBrowseService(t)
107+ repo := pushedRepo(t, "proj")
108+ commits := homeCommits(t, 1)
109+ head := commits[0].ID.String()
110+ revision, err := domain.ParseRevision(head)
111+ require.NoError(t, err)
112+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo.Name, mock.Anything).Return(ports.HistoryPage{Commits: commits}, nil).Once()
113+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo.Name, commits[0].ID, mock.Anything).Return(ports.RepoStats{}, nil).Once()
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{{Name: "COPYING", Path: "COPYING", Kind: domain.EntryFile}}}, nil).Once()
115+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything).Return(domain.BlobInfo{Binary: binary}, io.NopCloser(strings.NewReader("A custom license")), nil).Once()
116+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: browse})
117+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
118+ require.NotNil(t, row.License)
119+ assert.Equal(t, "Other", row.License.ID)
120+ assert.Contains(t, renderRow(t, row), `href="/proj/blob/`+head+`/COPYING" title="License: Other">Other</a>`)
121+ assert.Contains(t, renderRail(t, SummaryPage{License: row.License}), `<dt>license</dt><dd><a href="/proj/blob/`+head+`/COPYING">Other</a></dd>`)
122+ })
123+ }
124+}
125+
126+func TestLicenseFullTexts(t *testing.T) {
127+ t.Parallel()
128+ const grant = `Copyright (c) 2026 Example
129+
130+Permission is hereby granted, free of charge, to any person obtaining a copy
131+of this software and associated documentation files (the "Software"), to deal
132+in the Software without restriction, including without limitation the rights
133+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
134+copies of the Software, and to permit persons to whom the Software is
135+furnished to do so.`
136+ const notice = `The above copyright notice and this permission notice shall be included in
137+all copies or substantial portions of the Software.`
138+ const warranty = `THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
139+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
140+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
141+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
142+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
143+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
144+THE SOFTWARE.`
145+ const bsd = `Copyright (c) 2026 Example. All rights reserved.
146+Redistribution and use in source and binary forms, with or without
147+modification, are permitted provided that the following conditions are met:
148+1. Redistributions of source code must retain the above copyright notice,
149+ this list of conditions and the following disclaimer.
150+2. Redistributions in binary form must reproduce the above copyright notice,
151+ this list of conditions and the following disclaimer in the documentation
152+ and/or other materials provided with the distribution.`
153+ const endorsement = `3. Neither the name of the copyright holder nor the names of its
154+ contributors may be used to endorse or promote products derived from
155+ this software without specific prior written permission.`
156+ const disclaimer = `THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
157+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
158+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
159+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
160+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
161+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
162+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
163+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
164+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
165+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
166+POSSIBILITY OF SUCH DAMAGE.`
167+ const gnu = `Copyright (C) 2007 Free Software Foundation, Inc.
168+Everyone is permitted to copy and distribute verbatim copies of this license
169+document, but changing it is not allowed.
170+
171+Preamble
172+The licenses for most software and other practical works are designed to
173+take away your freedom to share and change the works. By contrast, the GNU
174+General Public License is intended to guarantee your freedom to share and
175+change all versions of a program--to make sure it remains free software
176+for all its users.`
177+ for _, tc := range []struct{ name, text, want string }{
178+ {"MIT", "MIT License\n" + grant + "\n" + notice + "\n" + warranty, "MIT"},
179+ {"MIT-0", "MIT No Attribution\n" + grant + "\n" + warranty, "MIT-0"},
180+ {"BSD-2", bsd + "\n" + disclaimer, "BSD-2-Clause"},
181+ {"BSD-3", bsd + "\n" + endorsement + "\n" + disclaimer, "BSD-3-Clause"},
182+ {"GPL", "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + strings.Repeat("\nTerms and conditions for copying, distribution and modification.", 10) + "\nUse with the GNU Affero General Public License version 3.", "GPL-3.0"},
183+ {"LGPL", "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + "\nThis version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by additional permissions.", "LGPL-3.0"},
184+ {"AGPL", "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n" + gnu + "\nNotwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network an opportunity to receive the Corresponding Source.", "AGPL-3.0"},
185+ } {
186+ t.Run(tc.name, func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
187+ }
188+}
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func TestLicenseID(t *testing.T) {
22+ t.Parallel()
23+ for _, tc := range []struct{ text, want string }{
24+ {"// SPDX-License-Identifier: MIT", "MIT"},
25+ {"<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->", "Apache-2.0 OR MIT"},
26+ {"/* SPDX-License-Identifier: GPL-3.0-or-later */", "GPL-3.0-or-later"},
27+ {"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007", "AGPL-3.0"},
28+ {"GNU GENERAL PUBLIC LICENSE Version 3" + strings.Repeat(" padding", 100) + "GNU Affero General Public License version 3", "GPL-3.0"},
29+ {"GNU GENERAL PUBLIC LICENSE Version 2", "GPL-2.0"},
30+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 2.1", "LGPL-2.1"},
31+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 3", "LGPL-3.0"},
32+ {"Apache License\nVersion 2.0, January 2004", "Apache-2.0"},
33+ {"Mozilla Public License Version 2.0", "MPL-2.0"},
34+ {"Permission is hereby granted, free of charge\nTHE SOFTWARE IS PROVIDED \"AS IS\"", "MIT-0"},
35+ {"Redistribution and use in source and binary forms ... neither the name", "BSD-3-Clause"},
36+ {"Redistribution and use in source and binary forms ... this list of conditions", "BSD-2-Clause"},
37+ {"Redistribution and use in source and binary forms ... all advertising materials ... neither the name", "Other"},
38+ {"Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee", "ISC"},
39+ {"Custom license mentioning MIT or Apache-2.0", "Other"},
40+ {"", "Other"},
41+ } {
42+ t.Run(tc.want+tc.text[:min(20, len(tc.text))], func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
43+ }
44+}
45+
46+func TestRepositoryLicenseCache(t *testing.T) {
47+ t.Parallel()
48+ for _, filename := range append(append([]string{}, licenseNames...), "LiCeNsE.MD", "missing", "directory") {
49+ t.Run(filename, func(t *testing.T) {
50+ t.Parallel()
51+ browse := mocks.NewBrowseService(t)
52+ repo := repoName(t, "proj")
53+ head := strings.Repeat("a", 40)
54+ entry := domain.TreeEntry{Name: filename, Path: filename, Kind: domain.EntryFile}
55+ if filename == "directory" {
56+ entry.Name, entry.Kind = "LICENSE", domain.EntryDirectory
57+ }
58+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{entry}}, nil).Once()
59+ found := filename != "missing" && filename != "directory"
60+ if found {
61+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("SPDX-License-Identifier: MIT")), nil).Once()
62+ }
63+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
64+ for range 2 {
65+ badge := srv.repositoryLicense(t.Context(), repo, head)
66+ if !found {
67+ assert.Nil(t, badge)
68+ continue
69+ }
70+ require.NotNil(t, badge)
71+ assert.Equal(t, "MIT", badge.ID)
72+ assert.Equal(t, "/proj/blob/"+head+"/"+filename, badge.Href)
73+ }
74+ })
75+ }
76+}
77+
78+func TestRepositoryLicenseRetriesErrorsAndNewHeads(t *testing.T) {
79+ t.Parallel()
80+ browse := mocks.NewBrowseService(t)
81+ repo := repoName(t, "proj")
82+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, errors.New("unavailable")).Once()
83+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Twice()
84+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
85+ for _, head := range []string{strings.Repeat("a", 40), strings.Repeat("a", 40), strings.Repeat("b", 40)} {
86+ assert.Nil(t, srv.repositoryLicense(context.Background(), repo, head))
87+ }
88+}
89+
90+func TestSummaryLicense(t *testing.T) {
91+ t.Parallel()
92+ assert.NotContains(t, renderRail(t, SummaryPage{}), "<dt>license</dt>")
93+ out := renderRail(t, SummaryPage{License: &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}})
94+ assert.Contains(t, out, `<dt>license</dt><dd><a href="/proj/blob/main/LICENSE">MIT</a></dd>`)
95+}
96+
97+func TestHomeLicenseFromDefaultHead(t *testing.T) {
98+ t.Parallel()
99+ for _, binary := range []bool{false, true} {
100+ name := "unknown text"
101+ if binary {
102+ name = "binary"
103+ }
104+ t.Run(name, func(t *testing.T) {
105+ t.Parallel()
106+ browse := mocks.NewBrowseService(t)
107+ repo := pushedRepo(t, "proj")
108+ commits := homeCommits(t, 1)
109+ head := commits[0].ID.String()
110+ revision, err := domain.ParseRevision(head)
111+ require.NoError(t, err)
112+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo.Name, mock.Anything).Return(ports.HistoryPage{Commits: commits}, nil).Once()
113+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo.Name, commits[0].ID, mock.Anything).Return(ports.RepoStats{}, nil).Once()
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{{Name: "COPYING", Path: "COPYING", Kind: domain.EntryFile}}}, nil).Once()
115+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything).Return(domain.BlobInfo{Binary: binary}, io.NopCloser(strings.NewReader("A custom license")), nil).Once()
116+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: browse})
117+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
118+ require.NotNil(t, row.License)
119+ assert.Equal(t, "Other", row.License.ID)
120+ assert.Contains(t, renderRow(t, row), `href="/proj/blob/`+head+`/COPYING" title="License: Other">Other</a>`)
121+ assert.Contains(t, renderRail(t, SummaryPage{License: row.License}), `<dt>license</dt><dd><a href="/proj/blob/`+head+`/COPYING">Other</a></dd>`)
122+ })
123+ }
124+}
125+
126+func TestLicenseFullTexts(t *testing.T) {
127+ t.Parallel()
128+ const grant = `Copyright (c) 2026 Example
129+
130+Permission is hereby granted, free of charge, to any person obtaining a copy
131+of this software and associated documentation files (the "Software"), to deal
132+in the Software without restriction, including without limitation the rights
133+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
134+copies of the Software, and to permit persons to whom the Software is
135+furnished to do so.`
136+ const notice = `The above copyright notice and this permission notice shall be included in
137+all copies or substantial portions of the Software.`
138+ const warranty = `THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
139+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
140+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
141+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
142+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
143+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
144+THE SOFTWARE.`
145+ const bsd = `Copyright (c) 2026 Example. All rights reserved.
146+Redistribution and use in source and binary forms, with or without
147+modification, are permitted provided that the following conditions are met:
148+1. Redistributions of source code must retain the above copyright notice,
149+ this list of conditions and the following disclaimer.
150+2. Redistributions in binary form must reproduce the above copyright notice,
151+ this list of conditions and the following disclaimer in the documentation
152+ and/or other materials provided with the distribution.`
153+ const endorsement = `3. Neither the name of the copyright holder nor the names of its
154+ contributors may be used to endorse or promote products derived from
155+ this software without specific prior written permission.`
156+ const disclaimer = `THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
157+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
158+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
159+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
160+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
161+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
162+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
163+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
164+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
165+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
166+POSSIBILITY OF SUCH DAMAGE.`
167+ const gnu = `Copyright (C) 2007 Free Software Foundation, Inc.
168+Everyone is permitted to copy and distribute verbatim copies of this license
169+document, but changing it is not allowed.
170+
171+Preamble
172+The licenses for most software and other practical works are designed to
173+take away your freedom to share and change the works. By contrast, the GNU
174+General Public License is intended to guarantee your freedom to share and
175+change all versions of a program--to make sure it remains free software
176+for all its users.`
177+ for _, tc := range []struct{ name, text, want string }{
178+ {"MIT", "MIT License\n" + grant + "\n" + notice + "\n" + warranty, "MIT"},
179+ {"MIT-0", "MIT No Attribution\n" + grant + "\n" + warranty, "MIT-0"},
180+ {"BSD-2", bsd + "\n" + disclaimer, "BSD-2-Clause"},
181+ {"BSD-3", bsd + "\n" + endorsement + "\n" + disclaimer, "BSD-3-Clause"},
182+ {"GPL", "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + strings.Repeat("\nTerms and conditions for copying, distribution and modification.", 10) + "\nUse with the GNU Affero General Public License version 3.", "GPL-3.0"},
183+ {"LGPL", "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + "\nThis version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by additional permissions.", "LGPL-3.0"},
184+ {"AGPL", "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n" + gnu + "\nNotwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network an opportunity to receive the Corresponding Source.", "AGPL-3.0"},
185+ } {
186+ t.Run(tc.name, func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
187+ }
188+}
M internal/adapters/web/pages.templ +5 -1
@@ -69,7 +69,7 @@ templ Home(l Layout, p HomePage) {
6969 templ Summary(l Layout, p SummaryPage) {
7070 @Shell(l, summaryRail(p)) {
7171 if p.Readme != nil {
72- @ReadmeCard(*p.Readme)
72+ @ReadmeCard(*p.Readme, p.Docs...)
7373 }
7474 }
7575 }
@@ -112,6 +112,10 @@ templ summaryRail(p SummaryPage) {
112112 <dt>{ f.Key }</dt>
113113 @FactValue(f)
114114 }
115+ if lic := p.License; lic != nil {
116+ <dt>license</dt>
117+ <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd>
118+ }
115119 if p.Latest != nil {
116120 @latestTagFact(*p.Latest)
117121 }
@@ -69,7 +69,7 @@ templ Home(l Layout, p HomePage) {
69 templ Summary(l Layout, p SummaryPage) {69 templ Summary(l Layout, p SummaryPage) {
70 @Shell(l, summaryRail(p)) {70 @Shell(l, summaryRail(p)) {
71 if p.Readme != nil {71 if p.Readme != nil {
72- @ReadmeCard(*p.Readme)72+ @ReadmeCard(*p.Readme, p.Docs...)
73 }73 }
74 }74 }
75 }75 }
@@ -112,6 +112,10 @@ templ summaryRail(p SummaryPage) {
112 <dt>{ f.Key }</dt>112 <dt>{ f.Key }</dt>
113 @FactValue(f)113 @FactValue(f)
114 }114 }
115+ if lic := p.License; lic != nil {
116+ <dt>license</dt>
117+ <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd>
118+ }
115 if p.Latest != nil {119 if p.Latest != nil {
116 @latestTagFact(*p.Latest)120 @latestTagFact(*p.Latest)
117 }121 }
M internal/adapters/web/pages_templ.go +331 -299
@@ -224,7 +224,7 @@ func Summary(l Layout, p SummaryPage) templ.Component {
224224 }
225225 ctx = templ.InitializeContext(ctx)
226226 if p.Readme != nil {
227- templ_7745c5c3_Err = ReadmeCard(*p.Readme).Render(ctx, templ_7745c5c3_Buffer)
227+ templ_7745c5c3_Err = ReadmeCard(*p.Readme, p.Docs...).Render(ctx, templ_7745c5c3_Buffer)
228228 if templ_7745c5c3_Err != nil {
229229 return templ_7745c5c3_Err
230230 }
@@ -528,21 +528,15 @@ func summaryRail(p SummaryPage) templ.Component {
528528 return templ_7745c5c3_Err
529529 }
530530 }
531- if p.Latest != nil {
532- templ_7745c5c3_Err = latestTagFact(*p.Latest).Render(ctx, templ_7745c5c3_Buffer)
533- if templ_7745c5c3_Err != nil {
534- return templ_7745c5c3_Err
535- }
536- }
537- if r := p.Release; r != nil {
538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dt>release</dt><dd><a href=\"")
531+ if lic := p.License; lic != nil {
532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dt>license</dt><dd><a href=\"")
539533 if templ_7745c5c3_Err != nil {
540534 return templ_7745c5c3_Err
541535 }
542536 var templ_7745c5c3_Var27 templ.SafeURL
543- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
537+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(lic.Href))
544538 if templ_7745c5c3_Err != nil {
545- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 36}
539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 117, Col: 41}
546540 }
547541 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
548542 if templ_7745c5c3_Err != nil {
@@ -553,116 +547,154 @@ func summaryRail(p SummaryPage) templ.Component {
553547 return templ_7745c5c3_Err
554548 }
555549 var templ_7745c5c3_Var28 string
556- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Tag)
550+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(lic.ID)
557551 if templ_7745c5c3_Err != nil {
558- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 46}
552+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 117, Col: 52}
559553 }
560554 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
561555 if templ_7745c5c3_Err != nil {
562556 return templ_7745c5c3_Err
563557 }
564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a>")
558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a></dd>")
559+ if templ_7745c5c3_Err != nil {
560+ return templ_7745c5c3_Err
561+ }
562+ }
563+ if p.Latest != nil {
564+ templ_7745c5c3_Err = latestTagFact(*p.Latest).Render(ctx, templ_7745c5c3_Buffer)
565+ if templ_7745c5c3_Err != nil {
566+ return templ_7745c5c3_Err
567+ }
568+ }
569+ if r := p.Release; r != nil {
570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<dt>release</dt><dd><a href=\"")
565571 if templ_7745c5c3_Err != nil {
566572 return templ_7745c5c3_Err
567573 }
568- var templ_7745c5c3_Var29 string
569- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
574+ var templ_7745c5c3_Var29 templ.SafeURL
575+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
570576 if templ_7745c5c3_Err != nil {
571- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 60}
577+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 125, Col: 36}
572578 }
573579 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
574580 if templ_7745c5c3_Err != nil {
575581 return templ_7745c5c3_Err
576582 }
577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " <time datetime=\"")
583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
578584 if templ_7745c5c3_Err != nil {
579585 return templ_7745c5c3_Err
580586 }
581587 var templ_7745c5c3_Var30 string
582- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
588+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.Tag)
583589 if templ_7745c5c3_Err != nil {
584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 30}
590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 125, Col: 46}
585591 }
586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
592+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
587593 if templ_7745c5c3_Err != nil {
588594 return templ_7745c5c3_Err
589595 }
590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a>")
591597 if templ_7745c5c3_Err != nil {
592598 return templ_7745c5c3_Err
593599 }
594600 var templ_7745c5c3_Var31 string
595- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
601+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
596602 if templ_7745c5c3_Err != nil {
597- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 49}
603+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 125, Col: 60}
598604 }
599- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
605+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
600606 if templ_7745c5c3_Err != nil {
601607 return templ_7745c5c3_Err
602608 }
603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " <time datetime=\"")
604610 if templ_7745c5c3_Err != nil {
605611 return templ_7745c5c3_Err
606612 }
607613 var templ_7745c5c3_Var32 string
608- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
614+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
609615 if templ_7745c5c3_Err != nil {
610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 60}
616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 30}
611617 }
612- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
618+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
613619 if templ_7745c5c3_Err != nil {
614620 return templ_7745c5c3_Err
615621 }
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</time>")
622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" title=\"")
617623 if templ_7745c5c3_Err != nil {
618624 return templ_7745c5c3_Err
619625 }
620626 var templ_7745c5c3_Var33 string
621- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
627+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
622628 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 77}
629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 49}
624630 }
625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
631+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
626632 if templ_7745c5c3_Err != nil {
627633 return templ_7745c5c3_Err
628634 }
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " <a href=\"")
635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
630636 if templ_7745c5c3_Err != nil {
631637 return templ_7745c5c3_Err
632638 }
633- var templ_7745c5c3_Var34 templ.SafeURL
634- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.AllHref))
639+ var templ_7745c5c3_Var34 string
640+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
635641 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 39}
642+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 60}
637643 }
638644 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
639645 if templ_7745c5c3_Err != nil {
640646 return templ_7745c5c3_Err
641647 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">all releases</a></dd>")
648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</time>")
649+ if templ_7745c5c3_Err != nil {
650+ return templ_7745c5c3_Err
651+ }
652+ var templ_7745c5c3_Var35 string
653+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
654+ if templ_7745c5c3_Err != nil {
655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 77}
656+ }
657+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
658+ if templ_7745c5c3_Err != nil {
659+ return templ_7745c5c3_Err
660+ }
661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " <a href=\"")
662+ if templ_7745c5c3_Err != nil {
663+ return templ_7745c5c3_Err
664+ }
665+ var templ_7745c5c3_Var36 templ.SafeURL
666+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.AllHref))
667+ if templ_7745c5c3_Err != nil {
668+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 39}
669+ }
670+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
671+ if templ_7745c5c3_Err != nil {
672+ return templ_7745c5c3_Err
673+ }
674+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">all releases</a></dd>")
643675 if templ_7745c5c3_Err != nil {
644676 return templ_7745c5c3_Err
645677 }
646678 }
647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</dl></section>")
679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</dl></section>")
648680 if templ_7745c5c3_Err != nil {
649681 return templ_7745c5c3_Err
650682 }
651683 if len(p.Recent) > 0 {
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
653685 if templ_7745c5c3_Err != nil {
654686 return templ_7745c5c3_Err
655687 }
656- var templ_7745c5c3_Var35 templ.SafeURL
657- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
688+ var templ_7745c5c3_Var37 templ.SafeURL
689+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
658690 if templ_7745c5c3_Err != nil {
659- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 129, Col: 105}
691+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 133, Col: 105}
660692 }
661- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
693+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
662694 if templ_7745c5c3_Err != nil {
663695 return templ_7745c5c3_Err
664696 }
665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">View all</a></h2>")
697+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">View all</a></h2>")
666698 if templ_7745c5c3_Err != nil {
667699 return templ_7745c5c3_Err
668700 }
@@ -670,7 +702,7 @@ func summaryRail(p SummaryPage) templ.Component {
670702 if templ_7745c5c3_Err != nil {
671703 return templ_7745c5c3_Err
672704 }
673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</section>")
705+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</section>")
674706 if templ_7745c5c3_Err != nil {
675707 return templ_7745c5c3_Err
676708 }
@@ -682,7 +714,7 @@ func summaryRail(p SummaryPage) templ.Component {
682714 }
683715 }
684716 if len(p.Outline) > 0 && p.Readme != nil {
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
717+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
686718 if templ_7745c5c3_Err != nil {
687719 return templ_7745c5c3_Err
688720 }
@@ -690,7 +722,7 @@ func summaryRail(p SummaryPage) templ.Component {
690722 if templ_7745c5c3_Err != nil {
691723 return templ_7745c5c3_Err
692724 }
693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</section>")
725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</section>")
694726 if templ_7745c5c3_Err != nil {
695727 return templ_7745c5c3_Err
696728 }
@@ -716,12 +748,12 @@ func Refs(l Layout, p RefsPage) templ.Component {
716748 }()
717749 }
718750 ctx = templ.InitializeContext(ctx)
719- templ_7745c5c3_Var36 := templ.GetChildren(ctx)
720- if templ_7745c5c3_Var36 == nil {
721- templ_7745c5c3_Var36 = templ.NopComponent
751+ templ_7745c5c3_Var38 := templ.GetChildren(ctx)
752+ if templ_7745c5c3_Var38 == nil {
753+ templ_7745c5c3_Var38 = templ.NopComponent
722754 }
723755 ctx = templ.ClearChildren(ctx)
724- templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
756+ templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
725757 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
726758 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
727759 if !templ_7745c5c3_IsBuffer {
@@ -733,7 +765,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
733765 }()
734766 }
735767 ctx = templ.InitializeContext(ctx)
736- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>")
768+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>")
737769 if templ_7745c5c3_Err != nil {
738770 return templ_7745c5c3_Err
739771 }
@@ -741,32 +773,32 @@ func Refs(l Layout, p RefsPage) templ.Component {
741773 if templ_7745c5c3_Err != nil {
742774 return templ_7745c5c3_Err
743775 }
744- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</table>")
776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</table>")
745777 if templ_7745c5c3_Err != nil {
746778 return templ_7745c5c3_Err
747779 }
748780 if p.Note != "" {
749- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<p class=\"note\">")
781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<p class=\"note\">")
750782 if templ_7745c5c3_Err != nil {
751783 return templ_7745c5c3_Err
752784 }
753- var templ_7745c5c3_Var38 string
754- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
785+ var templ_7745c5c3_Var40 string
786+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
755787 if templ_7745c5c3_Err != nil {
756- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 162, Col: 27}
788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 27}
757789 }
758- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
790+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
759791 if templ_7745c5c3_Err != nil {
760792 return templ_7745c5c3_Err
761793 }
762- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</p>")
794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</p>")
763795 if templ_7745c5c3_Err != nil {
764796 return templ_7745c5c3_Err
765797 }
766798 }
767799 return nil
768800 })
769- templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer)
801+ templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer)
770802 if templ_7745c5c3_Err != nil {
771803 return templ_7745c5c3_Err
772804 }
@@ -790,112 +822,112 @@ func refRow(kind string, r RefRow) templ.Component {
790822 }()
791823 }
792824 ctx = templ.InitializeContext(ctx)
793- templ_7745c5c3_Var39 := templ.GetChildren(ctx)
794- if templ_7745c5c3_Var39 == nil {
795- templ_7745c5c3_Var39 = templ.NopComponent
825+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
826+ if templ_7745c5c3_Var41 == nil {
827+ templ_7745c5c3_Var41 = templ.NopComponent
796828 }
797829 ctx = templ.ClearChildren(ctx)
798- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<tr")
830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<tr")
799831 if templ_7745c5c3_Err != nil {
800832 return templ_7745c5c3_Err
801833 }
802834 if r.Cursor {
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " data-cursor")
835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " data-cursor")
804836 if templ_7745c5c3_Err != nil {
805837 return templ_7745c5c3_Err
806838 }
807839 }
808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "><td class=\"c-kind mute\">")
840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "><td class=\"c-kind mute\">")
809841 if templ_7745c5c3_Err != nil {
810842 return templ_7745c5c3_Err
811843 }
812- var templ_7745c5c3_Var40 string
813- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
844+ var templ_7745c5c3_Var42 string
845+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
814846 if templ_7745c5c3_Err != nil {
815- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 169, Col: 32}
847+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 32}
816848 }
817- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
849+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
818850 if templ_7745c5c3_Err != nil {
819851 return templ_7745c5c3_Err
820852 }
821- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</td><td class=\"c-name\"><a href=\"")
853+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td><td class=\"c-name\"><a href=\"")
822854 if templ_7745c5c3_Err != nil {
823855 return templ_7745c5c3_Err
824856 }
825- var templ_7745c5c3_Var41 templ.SafeURL
826- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
857+ var templ_7745c5c3_Var43 templ.SafeURL
858+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
827859 if templ_7745c5c3_Err != nil {
828- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 34}
860+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 34}
829861 }
830- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
862+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
831863 if templ_7745c5c3_Err != nil {
832864 return templ_7745c5c3_Err
833865 }
834- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
866+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
835867 if templ_7745c5c3_Err != nil {
836868 return templ_7745c5c3_Err
837869 }
838- var templ_7745c5c3_Var42 string
839- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
870+ var templ_7745c5c3_Var44 string
871+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
840872 if templ_7745c5c3_Err != nil {
841- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 45}
873+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 45}
842874 }
843- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
875+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
844876 if templ_7745c5c3_Err != nil {
845877 return templ_7745c5c3_Err
846878 }
847- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</a> ")
879+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</a> ")
848880 if templ_7745c5c3_Err != nil {
849881 return templ_7745c5c3_Err
850882 }
851883 if r.Default {
852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span class=\"pill\">default</span>")
884+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"pill\">default</span>")
853885 if templ_7745c5c3_Err != nil {
854886 return templ_7745c5c3_Err
855887 }
856888 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</td><td class=\"c-sha\">")
889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</td><td class=\"c-sha\">")
858890 if templ_7745c5c3_Err != nil {
859891 return templ_7745c5c3_Err
860892 }
861893 if r.CHref != "" {
862- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a href=\"")
894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<a href=\"")
863895 if templ_7745c5c3_Err != nil {
864896 return templ_7745c5c3_Err
865897 }
866- var templ_7745c5c3_Var43 templ.SafeURL
867- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
898+ var templ_7745c5c3_Var45 templ.SafeURL
899+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
868900 if templ_7745c5c3_Err != nil {
869- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 36}
901+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 36}
870902 }
871- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
903+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
872904 if templ_7745c5c3_Err != nil {
873905 return templ_7745c5c3_Err
874906 }
875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">")
907+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">")
876908 if templ_7745c5c3_Err != nil {
877909 return templ_7745c5c3_Err
878910 }
879- var templ_7745c5c3_Var44 string
880- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
911+ var templ_7745c5c3_Var46 string
912+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
881913 if templ_7745c5c3_Err != nil {
882- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 48}
914+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 48}
883915 }
884- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
916+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
885917 if templ_7745c5c3_Err != nil {
886918 return templ_7745c5c3_Err
887919 }
888- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</a>")
920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</a>")
889921 if templ_7745c5c3_Err != nil {
890922 return templ_7745c5c3_Err
891923 }
892924 } else {
893- var templ_7745c5c3_Var45 string
894- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
925+ var templ_7745c5c3_Var47 string
926+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
895927 if templ_7745c5c3_Err != nil {
896- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 13}
928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 13}
897929 }
898- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
930+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
899931 if templ_7745c5c3_Err != nil {
900932 return templ_7745c5c3_Err
901933 }
@@ -904,69 +936,69 @@ func refRow(kind string, r RefRow) templ.Component {
904936 if templ_7745c5c3_Err != nil {
905937 return templ_7745c5c3_Err
906938 }
907- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</td><td class=\"c-sub\">")
939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</td><td class=\"c-sub\">")
908940 if templ_7745c5c3_Err != nil {
909941 return templ_7745c5c3_Err
910942 }
911- var templ_7745c5c3_Var46 string
912- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
943+ var templ_7745c5c3_Var48 string
944+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
913945 if templ_7745c5c3_Err != nil {
914- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 31}
946+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 188, Col: 31}
915947 }
916- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
948+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
917949 if templ_7745c5c3_Err != nil {
918950 return templ_7745c5c3_Err
919951 }
920- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</td><td class=\"c-when dim\">")
952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</td><td class=\"c-when dim\">")
921953 if templ_7745c5c3_Err != nil {
922954 return templ_7745c5c3_Err
923955 }
924956 if r.WhenAt != "" {
925- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<time datetime=\"")
957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<time datetime=\"")
926958 if templ_7745c5c3_Err != nil {
927959 return templ_7745c5c3_Err
928960 }
929- var templ_7745c5c3_Var47 string
930- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
961+ var templ_7745c5c3_Var49 string
962+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
931963 if templ_7745c5c3_Err != nil {
932- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 29}
964+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 29}
933965 }
934- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
966+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
935967 if templ_7745c5c3_Err != nil {
936968 return templ_7745c5c3_Err
937969 }
938- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" title=\"")
970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" title=\"")
939971 if templ_7745c5c3_Err != nil {
940972 return templ_7745c5c3_Err
941973 }
942- var templ_7745c5c3_Var48 string
943- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
974+ var templ_7745c5c3_Var50 string
975+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
944976 if templ_7745c5c3_Err != nil {
945- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 48}
977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 48}
946978 }
947- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
979+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
948980 if templ_7745c5c3_Err != nil {
949981 return templ_7745c5c3_Err
950982 }
951- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">")
952984 if templ_7745c5c3_Err != nil {
953985 return templ_7745c5c3_Err
954986 }
955- var templ_7745c5c3_Var49 string
956- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
987+ var templ_7745c5c3_Var51 string
988+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
957989 if templ_7745c5c3_Err != nil {
958- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 59}
990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 59}
959991 }
960- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
992+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
961993 if templ_7745c5c3_Err != nil {
962994 return templ_7745c5c3_Err
963995 }
964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</time>")
996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</time>")
965997 if templ_7745c5c3_Err != nil {
966998 return templ_7745c5c3_Err
967999 }
9681000 }
969- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</td></tr>")
1001+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</td></tr>")
9701002 if templ_7745c5c3_Err != nil {
9711003 return templ_7745c5c3_Err
9721004 }
@@ -990,12 +1022,12 @@ func refsRail(p RefsPage) templ.Component {
9901022 }()
9911023 }
9921024 ctx = templ.InitializeContext(ctx)
993- templ_7745c5c3_Var50 := templ.GetChildren(ctx)
994- if templ_7745c5c3_Var50 == nil {
995- templ_7745c5c3_Var50 = templ.NopComponent
1025+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
1026+ if templ_7745c5c3_Var52 == nil {
1027+ templ_7745c5c3_Var52 = templ.NopComponent
9961028 }
9971029 ctx = templ.ClearChildren(ctx)
998- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
1030+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
9991031 if templ_7745c5c3_Err != nil {
10001032 return templ_7745c5c3_Err
10011033 }
@@ -1003,7 +1035,7 @@ func refsRail(p RefsPage) templ.Component {
10031035 if templ_7745c5c3_Err != nil {
10041036 return templ_7745c5c3_Err
10051037 }
1006- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</section>")
1038+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</section>")
10071039 if templ_7745c5c3_Err != nil {
10081040 return templ_7745c5c3_Err
10091041 }
@@ -1028,12 +1060,12 @@ func State(l Layout, p StatePage) templ.Component {
10281060 }()
10291061 }
10301062 ctx = templ.InitializeContext(ctx)
1031- templ_7745c5c3_Var51 := templ.GetChildren(ctx)
1032- if templ_7745c5c3_Var51 == nil {
1033- templ_7745c5c3_Var51 = templ.NopComponent
1063+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1064+ if templ_7745c5c3_Var53 == nil {
1065+ templ_7745c5c3_Var53 = templ.NopComponent
10341066 }
10351067 ctx = templ.ClearChildren(ctx)
1036- templ_7745c5c3_Var52 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1068+ templ_7745c5c3_Var54 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
10371069 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
10381070 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
10391071 if !templ_7745c5c3_IsBuffer {
@@ -1045,51 +1077,51 @@ func State(l Layout, p StatePage) templ.Component {
10451077 }()
10461078 }
10471079 ctx = templ.InitializeContext(ctx)
1048- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
1080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
10491081 if templ_7745c5c3_Err != nil {
10501082 return templ_7745c5c3_Err
10511083 }
1052- var templ_7745c5c3_Var53 string
1053- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
1084+ var templ_7745c5c3_Var55 string
1085+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
10541086 if templ_7745c5c3_Err != nil {
1055- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 204, Col: 30}
1087+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 30}
10561088 }
1057- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1089+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
10581090 if templ_7745c5c3_Err != nil {
10591091 return templ_7745c5c3_Err
10601092 }
1061- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</p><h1 id=\"state-title\">")
1093+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p><h1 id=\"state-title\">")
10621094 if templ_7745c5c3_Err != nil {
10631095 return templ_7745c5c3_Err
10641096 }
1065- var templ_7745c5c3_Var54 string
1066- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1097+ var templ_7745c5c3_Var56 string
1098+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
10671099 if templ_7745c5c3_Err != nil {
1068- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 205, Col: 33}
1100+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 209, Col: 33}
10691101 }
1070- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1102+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
10711103 if templ_7745c5c3_Err != nil {
10721104 return templ_7745c5c3_Err
10731105 }
1074- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</h1>")
1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</h1>")
10751107 if templ_7745c5c3_Err != nil {
10761108 return templ_7745c5c3_Err
10771109 }
10781110 for _, line := range p.Lines {
1079- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"dim\">")
1111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<p class=\"dim\">")
10801112 if templ_7745c5c3_Err != nil {
10811113 return templ_7745c5c3_Err
10821114 }
1083- var templ_7745c5c3_Var55 string
1084- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1115+ var templ_7745c5c3_Var57 string
1116+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
10851117 if templ_7745c5c3_Err != nil {
1086- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 207, Col: 25}
1118+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 211, Col: 25}
10871119 }
1088- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1120+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
10891121 if templ_7745c5c3_Err != nil {
10901122 return templ_7745c5c3_Err
10911123 }
1092- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p>")
1124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</p>")
10931125 if templ_7745c5c3_Err != nil {
10941126 return templ_7745c5c3_Err
10951127 }
@@ -1100,49 +1132,49 @@ func State(l Layout, p StatePage) templ.Component {
11001132 return templ_7745c5c3_Err
11011133 }
11021134 }
1103- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"state-back\">")
1135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<p class=\"state-back\">")
11041136 if templ_7745c5c3_Err != nil {
11051137 return templ_7745c5c3_Err
11061138 }
11071139 for _, b := range p.Back {
1108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<a href=\"")
1140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<a href=\"")
11091141 if templ_7745c5c3_Err != nil {
11101142 return templ_7745c5c3_Err
11111143 }
1112- var templ_7745c5c3_Var56 templ.SafeURL
1113- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
1144+ var templ_7745c5c3_Var58 templ.SafeURL
1145+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
11141146 if templ_7745c5c3_Err != nil {
1115- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 36}
1147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 36}
11161148 }
1117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1149+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
11181150 if templ_7745c5c3_Err != nil {
11191151 return templ_7745c5c3_Err
11201152 }
1121- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">")
1153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\">")
11221154 if templ_7745c5c3_Err != nil {
11231155 return templ_7745c5c3_Err
11241156 }
1125- var templ_7745c5c3_Var57 string
1126- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
1157+ var templ_7745c5c3_Var59 string
1158+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
11271159 if templ_7745c5c3_Err != nil {
1128- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 48}
1160+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 48}
11291161 }
1130- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1162+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
11311163 if templ_7745c5c3_Err != nil {
11321164 return templ_7745c5c3_Err
11331165 }
1134- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</a>")
1166+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</a>")
11351167 if templ_7745c5c3_Err != nil {
11361168 return templ_7745c5c3_Err
11371169 }
11381170 }
1139- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</p></section>")
1171+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p></section>")
11401172 if templ_7745c5c3_Err != nil {
11411173 return templ_7745c5c3_Err
11421174 }
11431175 return nil
11441176 })
1145- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var52), templ_7745c5c3_Buffer)
1177+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), templ_7745c5c3_Buffer)
11461178 if templ_7745c5c3_Err != nil {
11471179 return templ_7745c5c3_Err
11481180 }
@@ -1171,12 +1203,12 @@ func Login(l Layout, p LoginPage) templ.Component {
11711203 }()
11721204 }
11731205 ctx = templ.InitializeContext(ctx)
1174- templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1175- if templ_7745c5c3_Var58 == nil {
1176- templ_7745c5c3_Var58 = templ.NopComponent
1206+ templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1207+ if templ_7745c5c3_Var60 == nil {
1208+ templ_7745c5c3_Var60 = templ.NopComponent
11771209 }
11781210 ctx = templ.ClearChildren(ctx)
1179- templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1211+ templ_7745c5c3_Var61 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
11801212 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
11811213 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
11821214 if !templ_7745c5c3_IsBuffer {
@@ -1188,75 +1220,75 @@ func Login(l Layout, p LoginPage) templ.Component {
11881220 }()
11891221 }
11901222 ctx = templ.InitializeContext(ctx)
1191- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1223+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
11921224 if templ_7745c5c3_Err != nil {
11931225 return templ_7745c5c3_Err
11941226 }
1195- var templ_7745c5c3_Var60 string
1196- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1227+ var templ_7745c5c3_Var62 string
1228+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
11971229 if templ_7745c5c3_Err != nil {
1198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 33}
1230+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 33}
11991231 }
1200- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1232+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
12011233 if templ_7745c5c3_Err != nil {
12021234 return templ_7745c5c3_Err
12031235 }
1204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</h1>")
1236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</h1>")
12051237 if templ_7745c5c3_Err != nil {
12061238 return templ_7745c5c3_Err
12071239 }
12081240 for _, line := range p.Lines {
1209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<p class=\"dim\">")
1241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"dim\">")
12101242 if templ_7745c5c3_Err != nil {
12111243 return templ_7745c5c3_Err
12121244 }
1213- var templ_7745c5c3_Var61 string
1214- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1245+ var templ_7745c5c3_Var63 string
1246+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(line)
12151247 if templ_7745c5c3_Err != nil {
1216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}
1248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 25}
12171249 }
1218- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
12191251 if templ_7745c5c3_Err != nil {
12201252 return templ_7745c5c3_Err
12211253 }
1222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</p>")
1254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p>")
12231255 if templ_7745c5c3_Err != nil {
12241256 return templ_7745c5c3_Err
12251257 }
12261258 }
1227- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<form class=\"state-act\" method=\"post\" action=\"")
1259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<form class=\"state-act\" method=\"post\" action=\"")
12281260 if templ_7745c5c3_Err != nil {
12291261 return templ_7745c5c3_Err
12301262 }
1231- var templ_7745c5c3_Var62 templ.SafeURL
1232- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1263+ var templ_7745c5c3_Var64 templ.SafeURL
1264+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
12331265 if templ_7745c5c3_Err != nil {
1234- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 73}
1266+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 237, Col: 73}
12351267 }
1236- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1268+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
12371269 if templ_7745c5c3_Err != nil {
12381270 return templ_7745c5c3_Err
12391271 }
1240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
1272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
12411273 if templ_7745c5c3_Err != nil {
12421274 return templ_7745c5c3_Err
12431275 }
1244- var templ_7745c5c3_Var63 string
1245- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1276+ var templ_7745c5c3_Var65 string
1277+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
12461278 if templ_7745c5c3_Err != nil {
1247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 234, Col: 56}
1279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 238, Col: 56}
12481280 }
1249- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1281+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
12501282 if templ_7745c5c3_Err != nil {
12511283 return templ_7745c5c3_Err
12521284 }
1253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
12541286 if templ_7745c5c3_Err != nil {
12551287 return templ_7745c5c3_Err
12561288 }
12571289 return nil
12581290 })
1259- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer)
1291+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var61), templ_7745c5c3_Buffer)
12601292 if templ_7745c5c3_Err != nil {
12611293 return templ_7745c5c3_Err
12621294 }
@@ -1282,12 +1314,12 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
12821314 }()
12831315 }
12841316 ctx = templ.InitializeContext(ctx)
1285- templ_7745c5c3_Var64 := templ.GetChildren(ctx)
1286- if templ_7745c5c3_Var64 == nil {
1287- templ_7745c5c3_Var64 = templ.NopComponent
1317+ templ_7745c5c3_Var66 := templ.GetChildren(ctx)
1318+ if templ_7745c5c3_Var66 == nil {
1319+ templ_7745c5c3_Var66 = templ.NopComponent
12881320 }
12891321 ctx = templ.ClearChildren(ctx)
1290- templ_7745c5c3_Var65 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1322+ templ_7745c5c3_Var67 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
12911323 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
12921324 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
12931325 if !templ_7745c5c3_IsBuffer {
@@ -1299,88 +1331,88 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
12991331 }()
13001332 }
13011333 ctx = templ.InitializeContext(ctx)
1302- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
13031335 if templ_7745c5c3_Err != nil {
13041336 return templ_7745c5c3_Err
13051337 }
1306- var templ_7745c5c3_Var66 string
1307- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1338+ var templ_7745c5c3_Var68 string
1339+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
13081340 if templ_7745c5c3_Err != nil {
1309- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 33}
1341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 33}
13101342 }
1311- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
13121344 if templ_7745c5c3_Err != nil {
13131345 return templ_7745c5c3_Err
13141346 }
1315- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
1347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
13161348 if templ_7745c5c3_Err != nil {
13171349 return templ_7745c5c3_Err
13181350 }
1319- var templ_7745c5c3_Var67 string
1320- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1351+ var templ_7745c5c3_Var69 string
1352+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
13211353 if templ_7745c5c3_Err != nil {
1322- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 87}
1354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 87}
13231355 }
1324- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
13251357 if templ_7745c5c3_Err != nil {
13261358 return templ_7745c5c3_Err
13271359 }
1328- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" data-finish=\"")
1360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" data-finish=\"")
13291361 if templ_7745c5c3_Err != nil {
13301362 return templ_7745c5c3_Err
13311363 }
1332- var templ_7745c5c3_Var68 string
1333- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1364+ var templ_7745c5c3_Var70 string
1365+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
13341366 if templ_7745c5c3_Err != nil {
1335- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 112}
1367+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 112}
13361368 }
1337- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
1369+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
13381370 if templ_7745c5c3_Err != nil {
13391371 return templ_7745c5c3_Err
13401372 }
1341- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\">")
1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\">")
13421374 if templ_7745c5c3_Err != nil {
13431375 return templ_7745c5c3_Err
13441376 }
1345- var templ_7745c5c3_Var69 string
1346- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1377+ var templ_7745c5c3_Var71 string
1378+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
13471379 if templ_7745c5c3_Err != nil {
1348- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 125}
1380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 125}
13491381 }
1350- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
13511383 if templ_7745c5c3_Err != nil {
13521384 return templ_7745c5c3_Err
13531385 }
1354- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
13551387 if templ_7745c5c3_Err != nil {
13561388 return templ_7745c5c3_Err
13571389 }
13581390 for _, line := range p.Lines {
1359- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<p class=\"dim\">")
1391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<p class=\"dim\">")
13601392 if templ_7745c5c3_Err != nil {
13611393 return templ_7745c5c3_Err
13621394 }
1363- var templ_7745c5c3_Var70 string
1364- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1395+ var templ_7745c5c3_Var72 string
1396+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(line)
13651397 if templ_7745c5c3_Err != nil {
1366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 25}
1398+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 256, Col: 25}
13671399 }
1368- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1400+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
13691401 if templ_7745c5c3_Err != nil {
13701402 return templ_7745c5c3_Err
13711403 }
1372- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</p>")
1404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</p>")
13731405 if templ_7745c5c3_Err != nil {
13741406 return templ_7745c5c3_Err
13751407 }
13761408 }
1377- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
13781410 if templ_7745c5c3_Err != nil {
13791411 return templ_7745c5c3_Err
13801412 }
13811413 return nil
13821414 })
1383- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var65), templ_7745c5c3_Buffer)
1415+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var67), templ_7745c5c3_Buffer)
13841416 if templ_7745c5c3_Err != nil {
13851417 return templ_7745c5c3_Err
13861418 }
@@ -1405,12 +1437,12 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14051437 }()
14061438 }
14071439 ctx = templ.InitializeContext(ctx)
1408- templ_7745c5c3_Var71 := templ.GetChildren(ctx)
1409- if templ_7745c5c3_Var71 == nil {
1410- templ_7745c5c3_Var71 = templ.NopComponent
1440+ templ_7745c5c3_Var73 := templ.GetChildren(ctx)
1441+ if templ_7745c5c3_Var73 == nil {
1442+ templ_7745c5c3_Var73 = templ.NopComponent
14111443 }
14121444 ctx = templ.ClearChildren(ctx)
1413- templ_7745c5c3_Var72 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1445+ templ_7745c5c3_Var74 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
14141446 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
14151447 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
14161448 if !templ_7745c5c3_IsBuffer {
@@ -1422,231 +1454,231 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14221454 }()
14231455 }
14241456 ctx = templ.InitializeContext(ctx)
1425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
14261458 if templ_7745c5c3_Err != nil {
14271459 return templ_7745c5c3_Err
14281460 }
1429- var templ_7745c5c3_Var73 string
1430- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1461+ var templ_7745c5c3_Var75 string
1462+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
14311463 if templ_7745c5c3_Err != nil {
1432- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 262, Col: 81}
1464+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 266, Col: 81}
14331465 }
1434- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1466+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
14351467 if templ_7745c5c3_Err != nil {
14361468 return templ_7745c5c3_Err
14371469 }
1438- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
14391471 if templ_7745c5c3_Err != nil {
14401472 return templ_7745c5c3_Err
14411473 }
14421474 if len(p.Rows) > 0 {
1443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
14441476 if templ_7745c5c3_Err != nil {
14451477 return templ_7745c5c3_Err
14461478 }
14471479 for _, r := range p.Rows {
1448- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<tr><td class=\"c-name\">")
1480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<tr><td class=\"c-name\">")
14491481 if templ_7745c5c3_Err != nil {
14501482 return templ_7745c5c3_Err
14511483 }
1452- var templ_7745c5c3_Var74 string
1453- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1484+ var templ_7745c5c3_Var76 string
1485+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
14541486 if templ_7745c5c3_Err != nil {
1455- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 34}
1487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 282, Col: 34}
14561488 }
1457- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
1489+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
14581490 if templ_7745c5c3_Err != nil {
14591491 return templ_7745c5c3_Err
14601492 }
1461- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-when nowrap\"><time")
1493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</td><td class=\"c-when nowrap\"><time")
14621494 if templ_7745c5c3_Err != nil {
14631495 return templ_7745c5c3_Err
14641496 }
14651497 if r.CreatedAt != "" {
1466- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " datetime=\"")
1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " datetime=\"")
14671499 if templ_7745c5c3_Err != nil {
14681500 return templ_7745c5c3_Err
14691501 }
1470- var templ_7745c5c3_Var75 string
1471- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1502+ var templ_7745c5c3_Var77 string
1503+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
14721504 if templ_7745c5c3_Err != nil {
1473- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 282, Col: 32}
1505+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 32}
14741506 }
1475- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1507+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
14761508 if templ_7745c5c3_Err != nil {
14771509 return templ_7745c5c3_Err
14781510 }
1479- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"")
1511+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\"")
14801512 if templ_7745c5c3_Err != nil {
14811513 return templ_7745c5c3_Err
14821514 }
14831515 }
1484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, ">")
1516+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, ">")
14851517 if templ_7745c5c3_Err != nil {
14861518 return templ_7745c5c3_Err
14871519 }
1488- var templ_7745c5c3_Var76 string
1489- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1520+ var templ_7745c5c3_Var78 string
1521+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
14901522 if templ_7745c5c3_Err != nil {
1491- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 284, Col: 20}
1523+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 288, Col: 20}
14921524 }
1493- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1525+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14941526 if templ_7745c5c3_Err != nil {
14951527 return templ_7745c5c3_Err
14961528 }
1497- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</time></td><td class=\"c-when nowrap\"><time")
1529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</time></td><td class=\"c-when nowrap\"><time")
14981530 if templ_7745c5c3_Err != nil {
14991531 return templ_7745c5c3_Err
15001532 }
15011533 if r.LastUsedAt != "" {
1502- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " datetime=\"")
1534+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, " datetime=\"")
15031535 if templ_7745c5c3_Err != nil {
15041536 return templ_7745c5c3_Err
15051537 }
1506- var templ_7745c5c3_Var77 string
1507- templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1538+ var templ_7745c5c3_Var79 string
1539+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
15081540 if templ_7745c5c3_Err != nil {
1509- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 33}
1541+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 293, Col: 33}
15101542 }
1511- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1543+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
15121544 if templ_7745c5c3_Err != nil {
15131545 return templ_7745c5c3_Err
15141546 }
1515- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\"")
1547+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\"")
15161548 if templ_7745c5c3_Err != nil {
15171549 return templ_7745c5c3_Err
15181550 }
15191551 }
1520- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, ">")
1552+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, ">")
15211553 if templ_7745c5c3_Err != nil {
15221554 return templ_7745c5c3_Err
15231555 }
1524- var templ_7745c5c3_Var78 string
1525- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1556+ var templ_7745c5c3_Var80 string
1557+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
15261558 if templ_7745c5c3_Err != nil {
1527- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 291, Col: 21}
1559+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 295, Col: 21}
15281560 }
1529- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1561+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
15301562 if templ_7745c5c3_Err != nil {
15311563 return templ_7745c5c3_Err
15321564 }
1533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</time></td><td class=\"c-sync\">")
1565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</time></td><td class=\"c-sync\">")
15341566 if templ_7745c5c3_Err != nil {
15351567 return templ_7745c5c3_Err
15361568 }
15371569 if r.Synced {
1538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "yes")
1570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "yes")
15391571 if templ_7745c5c3_Err != nil {
15401572 return templ_7745c5c3_Err
15411573 }
15421574 } else {
1543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "no")
1575+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "no")
15441576 if templ_7745c5c3_Err != nil {
15451577 return templ_7745c5c3_Err
15461578 }
15471579 }
1548- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1580+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
15491581 if templ_7745c5c3_Err != nil {
15501582 return templ_7745c5c3_Err
15511583 }
1552- var templ_7745c5c3_Var79 templ.SafeURL
1553- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1584+ var templ_7745c5c3_Var81 templ.SafeURL
1585+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
15541586 if templ_7745c5c3_Err != nil {
1555- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 301, Col: 60}
1587+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 60}
15561588 }
1557- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
1589+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
15581590 if templ_7745c5c3_Err != nil {
15591591 return templ_7745c5c3_Err
15601592 }
1561- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
15621594 if templ_7745c5c3_Err != nil {
15631595 return templ_7745c5c3_Err
15641596 }
1565- var templ_7745c5c3_Var80 string
1566- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1597+ var templ_7745c5c3_Var82 string
1598+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
15671599 if templ_7745c5c3_Err != nil {
1568- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 302, Col: 89}
1600+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 306, Col: 89}
15691601 }
1570- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1602+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
15711603 if templ_7745c5c3_Err != nil {
15721604 return templ_7745c5c3_Err
15731605 }
1574- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\">Remove</button></form></td></tr>")
1606+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">Remove</button></form></td></tr>")
15751607 if templ_7745c5c3_Err != nil {
15761608 return templ_7745c5c3_Err
15771609 }
15781610 }
1579- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</tbody></table>")
1611+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</tbody></table>")
15801612 if templ_7745c5c3_Err != nil {
15811613 return templ_7745c5c3_Err
15821614 }
15831615 } else {
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"lede dim\">no passkeys yet.</p>")
1616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<p class=\"lede dim\">no passkeys yet.</p>")
15851617 if templ_7745c5c3_Err != nil {
15861618 return templ_7745c5c3_Err
15871619 }
15881620 }
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " ")
1621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, " ")
15901622 if templ_7745c5c3_Err != nil {
15911623 return templ_7745c5c3_Err
15921624 }
15931625 if p.Full {
1594- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"dim\">")
1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<p class=\"dim\">")
15951627 if templ_7745c5c3_Err != nil {
15961628 return templ_7745c5c3_Err
15971629 }
1598- var templ_7745c5c3_Var81 string
1599- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1630+ var templ_7745c5c3_Var83 string
1631+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
16001632 if templ_7745c5c3_Err != nil {
1601- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 313, Col: 31}
1633+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 317, Col: 31}
16021634 }
1603- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1635+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
16041636 if templ_7745c5c3_Err != nil {
16051637 return templ_7745c5c3_Err
16061638 }
1607- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1639+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " passkeys are registered, the most there can be. Remove one to add another.</p>")
16081640 if templ_7745c5c3_Err != nil {
16091641 return templ_7745c5c3_Err
16101642 }
16111643 } else {
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"")
1644+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"")
16131645 if templ_7745c5c3_Err != nil {
16141646 return templ_7745c5c3_Err
16151647 }
1616- var templ_7745c5c3_Var82 string
1617- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1648+ var templ_7745c5c3_Var84 string
1649+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
16181650 if templ_7745c5c3_Err != nil {
1619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 318, Col: 90}
1651+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 322, Col: 90}
16201652 }
1621- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1653+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
16221654 if templ_7745c5c3_Err != nil {
16231655 return templ_7745c5c3_Err
16241656 }
1625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\" data-finish=\"")
1657+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" data-finish=\"")
16261658 if templ_7745c5c3_Err != nil {
16271659 return templ_7745c5c3_Err
16281660 }
1629- var templ_7745c5c3_Var83 string
1630- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1661+ var templ_7745c5c3_Var85 string
1662+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
16311663 if templ_7745c5c3_Err != nil {
1632- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 318, Col: 115}
1664+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 322, Col: 115}
16331665 }
1634- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
1666+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
16351667 if templ_7745c5c3_Err != nil {
16361668 return templ_7745c5c3_Err
16371669 }
1638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">Add a passkey</button></form>")
1670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">Add a passkey</button></form>")
16391671 if templ_7745c5c3_Err != nil {
16401672 return templ_7745c5c3_Err
16411673 }
16421674 }
1643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
16441676 if templ_7745c5c3_Err != nil {
16451677 return templ_7745c5c3_Err
16461678 }
16471679 return nil
16481680 })
1649- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var72), templ_7745c5c3_Buffer)
1681+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var74), templ_7745c5c3_Buffer)
16501682 if templ_7745c5c3_Err != nil {
16511683 return templ_7745c5c3_Err
16521684 }
@@ -224,7 +224,7 @@ func Summary(l Layout, p SummaryPage) templ.Component {
224 }224 }
225 ctx = templ.InitializeContext(ctx)225 ctx = templ.InitializeContext(ctx)
226 if p.Readme != nil {226 if p.Readme != nil {
227- templ_7745c5c3_Err = ReadmeCard(*p.Readme).Render(ctx, templ_7745c5c3_Buffer)227+ templ_7745c5c3_Err = ReadmeCard(*p.Readme, p.Docs...).Render(ctx, templ_7745c5c3_Buffer)
228 if templ_7745c5c3_Err != nil {228 if templ_7745c5c3_Err != nil {
229 return templ_7745c5c3_Err229 return templ_7745c5c3_Err
230 }230 }
@@ -528,21 +528,15 @@ func summaryRail(p SummaryPage) templ.Component {
528 return templ_7745c5c3_Err528 return templ_7745c5c3_Err
529 }529 }
530 }530 }
531- if p.Latest != nil {531+ if lic := p.License; lic != nil {
532- templ_7745c5c3_Err = latestTagFact(*p.Latest).Render(ctx, templ_7745c5c3_Buffer)532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dt>license</dt><dd><a href=\"")
533- if templ_7745c5c3_Err != nil {
534- return templ_7745c5c3_Err
535- }
536- }
537- if r := p.Release; r != nil {
538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dt>release</dt><dd><a href=\"")
539 if templ_7745c5c3_Err != nil {533 if templ_7745c5c3_Err != nil {
540 return templ_7745c5c3_Err534 return templ_7745c5c3_Err
541 }535 }
542 var templ_7745c5c3_Var27 templ.SafeURL536 var templ_7745c5c3_Var27 templ.SafeURL
543- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))537+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(lic.Href))
544 if templ_7745c5c3_Err != nil {538 if templ_7745c5c3_Err != nil {
545- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 36}539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 117, Col: 41}
546 }540 }
547 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))541 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
548 if templ_7745c5c3_Err != nil {542 if templ_7745c5c3_Err != nil {
@@ -553,116 +547,154 @@ func summaryRail(p SummaryPage) templ.Component {
553 return templ_7745c5c3_Err547 return templ_7745c5c3_Err
554 }548 }
555 var templ_7745c5c3_Var28 string549 var templ_7745c5c3_Var28 string
556- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Tag)550+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(lic.ID)
557 if templ_7745c5c3_Err != nil {551 if templ_7745c5c3_Err != nil {
558- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 46}552+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 117, Col: 52}
559 }553 }
560 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))554 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
561 if templ_7745c5c3_Err != nil {555 if templ_7745c5c3_Err != nil {
562 return templ_7745c5c3_Err556 return templ_7745c5c3_Err
563 }557 }
564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a>")558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</a></dd>")
559+ if templ_7745c5c3_Err != nil {
560+ return templ_7745c5c3_Err
561+ }
562+ }
563+ if p.Latest != nil {
564+ templ_7745c5c3_Err = latestTagFact(*p.Latest).Render(ctx, templ_7745c5c3_Buffer)
565+ if templ_7745c5c3_Err != nil {
566+ return templ_7745c5c3_Err
567+ }
568+ }
569+ if r := p.Release; r != nil {
570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<dt>release</dt><dd><a href=\"")
565 if templ_7745c5c3_Err != nil {571 if templ_7745c5c3_Err != nil {
566 return templ_7745c5c3_Err572 return templ_7745c5c3_Err
567 }573 }
568- var templ_7745c5c3_Var29 string574+ var templ_7745c5c3_Var29 templ.SafeURL
569- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")575+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
570 if templ_7745c5c3_Err != nil {576 if templ_7745c5c3_Err != nil {
571- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 60}577+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 125, Col: 36}
572 }578 }
573 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))579 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
574 if templ_7745c5c3_Err != nil {580 if templ_7745c5c3_Err != nil {
575 return templ_7745c5c3_Err581 return templ_7745c5c3_Err
576 }582 }
577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " <time datetime=\"")583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
578 if templ_7745c5c3_Err != nil {584 if templ_7745c5c3_Err != nil {
579 return templ_7745c5c3_Err585 return templ_7745c5c3_Err
580 }586 }
581 var templ_7745c5c3_Var30 string587 var templ_7745c5c3_Var30 string
582- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)588+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.Tag)
583 if templ_7745c5c3_Err != nil {589 if templ_7745c5c3_Err != nil {
584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 30}590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 125, Col: 46}
585 }591 }
586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)592+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
587 if templ_7745c5c3_Err != nil {593 if templ_7745c5c3_Err != nil {
588 return templ_7745c5c3_Err594 return templ_7745c5c3_Err
589 }595 }
590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a>")
591 if templ_7745c5c3_Err != nil {597 if templ_7745c5c3_Err != nil {
592 return templ_7745c5c3_Err598 return templ_7745c5c3_Err
593 }599 }
594 var templ_7745c5c3_Var31 string600 var templ_7745c5c3_Var31 string
595- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)601+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
596 if templ_7745c5c3_Err != nil {602 if templ_7745c5c3_Err != nil {
597- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 49}603+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 125, Col: 60}
598 }604 }
599- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)605+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
600 if templ_7745c5c3_Err != nil {606 if templ_7745c5c3_Err != nil {
601 return templ_7745c5c3_Err607 return templ_7745c5c3_Err
602 }608 }
603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " <time datetime=\"")
604 if templ_7745c5c3_Err != nil {610 if templ_7745c5c3_Err != nil {
605 return templ_7745c5c3_Err611 return templ_7745c5c3_Err
606 }612 }
607 var templ_7745c5c3_Var32 string613 var templ_7745c5c3_Var32 string
608- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)614+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
609 if templ_7745c5c3_Err != nil {615 if templ_7745c5c3_Err != nil {
610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 60}616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 30}
611 }617 }
612- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))618+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
613 if templ_7745c5c3_Err != nil {619 if templ_7745c5c3_Err != nil {
614 return templ_7745c5c3_Err620 return templ_7745c5c3_Err
615 }621 }
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</time>")622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" title=\"")
617 if templ_7745c5c3_Err != nil {623 if templ_7745c5c3_Err != nil {
618 return templ_7745c5c3_Err624 return templ_7745c5c3_Err
619 }625 }
620 var templ_7745c5c3_Var33 string626 var templ_7745c5c3_Var33 string
621- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")627+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
622 if templ_7745c5c3_Err != nil {628 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 122, Col: 77}629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 49}
624 }630 }
625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))631+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
626 if templ_7745c5c3_Err != nil {632 if templ_7745c5c3_Err != nil {
627 return templ_7745c5c3_Err633 return templ_7745c5c3_Err
628 }634 }
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " <a href=\"")635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
630 if templ_7745c5c3_Err != nil {636 if templ_7745c5c3_Err != nil {
631 return templ_7745c5c3_Err637 return templ_7745c5c3_Err
632 }638 }
633- var templ_7745c5c3_Var34 templ.SafeURL639+ var templ_7745c5c3_Var34 string
634- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.AllHref))640+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
635 if templ_7745c5c3_Err != nil {641 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 39}642+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 60}
637 }643 }
638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))644 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
639 if templ_7745c5c3_Err != nil {645 if templ_7745c5c3_Err != nil {
640 return templ_7745c5c3_Err646 return templ_7745c5c3_Err
641 }647 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">all releases</a></dd>")648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</time>")
649+ if templ_7745c5c3_Err != nil {
650+ return templ_7745c5c3_Err
651+ }
652+ var templ_7745c5c3_Var35 string
653+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
654+ if templ_7745c5c3_Err != nil {
655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 126, Col: 77}
656+ }
657+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
658+ if templ_7745c5c3_Err != nil {
659+ return templ_7745c5c3_Err
660+ }
661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " <a href=\"")
662+ if templ_7745c5c3_Err != nil {
663+ return templ_7745c5c3_Err
664+ }
665+ var templ_7745c5c3_Var36 templ.SafeURL
666+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.AllHref))
667+ if templ_7745c5c3_Err != nil {
668+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 39}
669+ }
670+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
671+ if templ_7745c5c3_Err != nil {
672+ return templ_7745c5c3_Err
673+ }
674+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">all releases</a></dd>")
643 if templ_7745c5c3_Err != nil {675 if templ_7745c5c3_Err != nil {
644 return templ_7745c5c3_Err676 return templ_7745c5c3_Err
645 }677 }
646 }678 }
647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</dl></section>")679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</dl></section>")
648 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
649 return templ_7745c5c3_Err681 return templ_7745c5c3_Err
650 }682 }
651 if len(p.Recent) > 0 {683 if len(p.Recent) > 0 {
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
653 if templ_7745c5c3_Err != nil {685 if templ_7745c5c3_Err != nil {
654 return templ_7745c5c3_Err686 return templ_7745c5c3_Err
655 }687 }
656- var templ_7745c5c3_Var35 templ.SafeURL688+ var templ_7745c5c3_Var37 templ.SafeURL
657- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))689+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
658 if templ_7745c5c3_Err != nil {690 if templ_7745c5c3_Err != nil {
659- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 129, Col: 105}691+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 133, Col: 105}
660 }692 }
661- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))693+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
662 if templ_7745c5c3_Err != nil {694 if templ_7745c5c3_Err != nil {
663 return templ_7745c5c3_Err695 return templ_7745c5c3_Err
664 }696 }
665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">View all</a></h2>")697+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">View all</a></h2>")
666 if templ_7745c5c3_Err != nil {698 if templ_7745c5c3_Err != nil {
667 return templ_7745c5c3_Err699 return templ_7745c5c3_Err
668 }700 }
@@ -670,7 +702,7 @@ func summaryRail(p SummaryPage) templ.Component {
670 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
671 return templ_7745c5c3_Err703 return templ_7745c5c3_Err
672 }704 }
673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</section>")705+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</section>")
674 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
675 return templ_7745c5c3_Err707 return templ_7745c5c3_Err
676 }708 }
@@ -682,7 +714,7 @@ func summaryRail(p SummaryPage) templ.Component {
682 }714 }
683 }715 }
684 if len(p.Outline) > 0 && p.Readme != nil {716 if len(p.Outline) > 0 && p.Readme != nil {
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")717+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
686 if templ_7745c5c3_Err != nil {718 if templ_7745c5c3_Err != nil {
687 return templ_7745c5c3_Err719 return templ_7745c5c3_Err
688 }720 }
@@ -690,7 +722,7 @@ func summaryRail(p SummaryPage) templ.Component {
690 if templ_7745c5c3_Err != nil {722 if templ_7745c5c3_Err != nil {
691 return templ_7745c5c3_Err723 return templ_7745c5c3_Err
692 }724 }
693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</section>")725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</section>")
694 if templ_7745c5c3_Err != nil {726 if templ_7745c5c3_Err != nil {
695 return templ_7745c5c3_Err727 return templ_7745c5c3_Err
696 }728 }
@@ -716,12 +748,12 @@ func Refs(l Layout, p RefsPage) templ.Component {
716 }()748 }()
717 }749 }
718 ctx = templ.InitializeContext(ctx)750 ctx = templ.InitializeContext(ctx)
719- templ_7745c5c3_Var36 := templ.GetChildren(ctx)751+ templ_7745c5c3_Var38 := templ.GetChildren(ctx)
720- if templ_7745c5c3_Var36 == nil {752+ if templ_7745c5c3_Var38 == nil {
721- templ_7745c5c3_Var36 = templ.NopComponent753+ templ_7745c5c3_Var38 = templ.NopComponent
722 }754 }
723 ctx = templ.ClearChildren(ctx)755 ctx = templ.ClearChildren(ctx)
724- templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {756+ templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
725 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context757 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
726 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)758 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
727 if !templ_7745c5c3_IsBuffer {759 if !templ_7745c5c3_IsBuffer {
@@ -733,7 +765,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
733 }()765 }()
734 }766 }
735 ctx = templ.InitializeContext(ctx)767 ctx = templ.InitializeContext(ctx)
736- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>")768+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>")
737 if templ_7745c5c3_Err != nil {769 if templ_7745c5c3_Err != nil {
738 return templ_7745c5c3_Err770 return templ_7745c5c3_Err
739 }771 }
@@ -741,32 +773,32 @@ func Refs(l Layout, p RefsPage) templ.Component {
741 if templ_7745c5c3_Err != nil {773 if templ_7745c5c3_Err != nil {
742 return templ_7745c5c3_Err774 return templ_7745c5c3_Err
743 }775 }
744- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</table>")776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</table>")
745 if templ_7745c5c3_Err != nil {777 if templ_7745c5c3_Err != nil {
746 return templ_7745c5c3_Err778 return templ_7745c5c3_Err
747 }779 }
748 if p.Note != "" {780 if p.Note != "" {
749- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<p class=\"note\">")781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<p class=\"note\">")
750 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
751 return templ_7745c5c3_Err783 return templ_7745c5c3_Err
752 }784 }
753- var templ_7745c5c3_Var38 string785+ var templ_7745c5c3_Var40 string
754- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)786+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
755 if templ_7745c5c3_Err != nil {787 if templ_7745c5c3_Err != nil {
756- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 162, Col: 27}788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 27}
757 }789 }
758- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))790+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
759 if templ_7745c5c3_Err != nil {791 if templ_7745c5c3_Err != nil {
760 return templ_7745c5c3_Err792 return templ_7745c5c3_Err
761 }793 }
762- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</p>")794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</p>")
763 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
764 return templ_7745c5c3_Err796 return templ_7745c5c3_Err
765 }797 }
766 }798 }
767 return nil799 return nil
768 })800 })
769- templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer)801+ templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer)
770 if templ_7745c5c3_Err != nil {802 if templ_7745c5c3_Err != nil {
771 return templ_7745c5c3_Err803 return templ_7745c5c3_Err
772 }804 }
@@ -790,112 +822,112 @@ func refRow(kind string, r RefRow) templ.Component {
790 }()822 }()
791 }823 }
792 ctx = templ.InitializeContext(ctx)824 ctx = templ.InitializeContext(ctx)
793- templ_7745c5c3_Var39 := templ.GetChildren(ctx)825+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
794- if templ_7745c5c3_Var39 == nil {826+ if templ_7745c5c3_Var41 == nil {
795- templ_7745c5c3_Var39 = templ.NopComponent827+ templ_7745c5c3_Var41 = templ.NopComponent
796 }828 }
797 ctx = templ.ClearChildren(ctx)829 ctx = templ.ClearChildren(ctx)
798- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<tr")830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<tr")
799 if templ_7745c5c3_Err != nil {831 if templ_7745c5c3_Err != nil {
800 return templ_7745c5c3_Err832 return templ_7745c5c3_Err
801 }833 }
802 if r.Cursor {834 if r.Cursor {
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " data-cursor")835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " data-cursor")
804 if templ_7745c5c3_Err != nil {836 if templ_7745c5c3_Err != nil {
805 return templ_7745c5c3_Err837 return templ_7745c5c3_Err
806 }838 }
807 }839 }
808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "><td class=\"c-kind mute\">")840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "><td class=\"c-kind mute\">")
809 if templ_7745c5c3_Err != nil {841 if templ_7745c5c3_Err != nil {
810 return templ_7745c5c3_Err842 return templ_7745c5c3_Err
811 }843 }
812- var templ_7745c5c3_Var40 string844+ var templ_7745c5c3_Var42 string
813- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(kind)845+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
814 if templ_7745c5c3_Err != nil {846 if templ_7745c5c3_Err != nil {
815- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 169, Col: 32}847+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 32}
816 }848 }
817- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))849+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
818 if templ_7745c5c3_Err != nil {850 if templ_7745c5c3_Err != nil {
819 return templ_7745c5c3_Err851 return templ_7745c5c3_Err
820 }852 }
821- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</td><td class=\"c-name\"><a href=\"")853+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td><td class=\"c-name\"><a href=\"")
822 if templ_7745c5c3_Err != nil {854 if templ_7745c5c3_Err != nil {
823 return templ_7745c5c3_Err855 return templ_7745c5c3_Err
824 }856 }
825- var templ_7745c5c3_Var41 templ.SafeURL857+ var templ_7745c5c3_Var43 templ.SafeURL
826- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))858+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
827 if templ_7745c5c3_Err != nil {859 if templ_7745c5c3_Err != nil {
828- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 34}860+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 34}
829 }861 }
830- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))862+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
831 if templ_7745c5c3_Err != nil {863 if templ_7745c5c3_Err != nil {
832 return templ_7745c5c3_Err864 return templ_7745c5c3_Err
833 }865 }
834- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")866+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
835 if templ_7745c5c3_Err != nil {867 if templ_7745c5c3_Err != nil {
836 return templ_7745c5c3_Err868 return templ_7745c5c3_Err
837 }869 }
838- var templ_7745c5c3_Var42 string870+ var templ_7745c5c3_Var44 string
839- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)871+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
840 if templ_7745c5c3_Err != nil {872 if templ_7745c5c3_Err != nil {
841- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 45}873+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 45}
842 }874 }
843- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))875+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
844 if templ_7745c5c3_Err != nil {876 if templ_7745c5c3_Err != nil {
845 return templ_7745c5c3_Err877 return templ_7745c5c3_Err
846 }878 }
847- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</a> ")879+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</a> ")
848 if templ_7745c5c3_Err != nil {880 if templ_7745c5c3_Err != nil {
849 return templ_7745c5c3_Err881 return templ_7745c5c3_Err
850 }882 }
851 if r.Default {883 if r.Default {
852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span class=\"pill\">default</span>")884+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"pill\">default</span>")
853 if templ_7745c5c3_Err != nil {885 if templ_7745c5c3_Err != nil {
854 return templ_7745c5c3_Err886 return templ_7745c5c3_Err
855 }887 }
856 }888 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</td><td class=\"c-sha\">")889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</td><td class=\"c-sha\">")
858 if templ_7745c5c3_Err != nil {890 if templ_7745c5c3_Err != nil {
859 return templ_7745c5c3_Err891 return templ_7745c5c3_Err
860 }892 }
861 if r.CHref != "" {893 if r.CHref != "" {
862- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a href=\"")894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<a href=\"")
863 if templ_7745c5c3_Err != nil {895 if templ_7745c5c3_Err != nil {
864 return templ_7745c5c3_Err896 return templ_7745c5c3_Err
865 }897 }
866- var templ_7745c5c3_Var43 templ.SafeURL898+ var templ_7745c5c3_Var45 templ.SafeURL
867- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))899+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
868 if templ_7745c5c3_Err != nil {900 if templ_7745c5c3_Err != nil {
869- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 36}901+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 36}
870 }902 }
871- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))903+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
872 if templ_7745c5c3_Err != nil {904 if templ_7745c5c3_Err != nil {
873 return templ_7745c5c3_Err905 return templ_7745c5c3_Err
874 }906 }
875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">")907+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">")
876 if templ_7745c5c3_Err != nil {908 if templ_7745c5c3_Err != nil {
877 return templ_7745c5c3_Err909 return templ_7745c5c3_Err
878 }910 }
879- var templ_7745c5c3_Var44 string911+ var templ_7745c5c3_Var46 string
880- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)912+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
881 if templ_7745c5c3_Err != nil {913 if templ_7745c5c3_Err != nil {
882- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 48}914+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 48}
883 }915 }
884- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))916+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
885 if templ_7745c5c3_Err != nil {917 if templ_7745c5c3_Err != nil {
886 return templ_7745c5c3_Err918 return templ_7745c5c3_Err
887 }919 }
888- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</a>")920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</a>")
889 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
890 return templ_7745c5c3_Err922 return templ_7745c5c3_Err
891 }923 }
892 } else {924 } else {
893- var templ_7745c5c3_Var45 string925+ var templ_7745c5c3_Var47 string
894- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)926+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
895 if templ_7745c5c3_Err != nil {927 if templ_7745c5c3_Err != nil {
896- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 13}928+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 13}
897 }929 }
898- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))930+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
899 if templ_7745c5c3_Err != nil {931 if templ_7745c5c3_Err != nil {
900 return templ_7745c5c3_Err932 return templ_7745c5c3_Err
901 }933 }
@@ -904,69 +936,69 @@ func refRow(kind string, r RefRow) templ.Component {
904 if templ_7745c5c3_Err != nil {936 if templ_7745c5c3_Err != nil {
905 return templ_7745c5c3_Err937 return templ_7745c5c3_Err
906 }938 }
907- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</td><td class=\"c-sub\">")939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</td><td class=\"c-sub\">")
908 if templ_7745c5c3_Err != nil {940 if templ_7745c5c3_Err != nil {
909 return templ_7745c5c3_Err941 return templ_7745c5c3_Err
910 }942 }
911- var templ_7745c5c3_Var46 string943+ var templ_7745c5c3_Var48 string
912- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)944+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
913 if templ_7745c5c3_Err != nil {945 if templ_7745c5c3_Err != nil {
914- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 31}946+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 188, Col: 31}
915 }947 }
916- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))948+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
917 if templ_7745c5c3_Err != nil {949 if templ_7745c5c3_Err != nil {
918 return templ_7745c5c3_Err950 return templ_7745c5c3_Err
919 }951 }
920- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</td><td class=\"c-when dim\">")952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</td><td class=\"c-when dim\">")
921 if templ_7745c5c3_Err != nil {953 if templ_7745c5c3_Err != nil {
922 return templ_7745c5c3_Err954 return templ_7745c5c3_Err
923 }955 }
924 if r.WhenAt != "" {956 if r.WhenAt != "" {
925- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<time datetime=\"")957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<time datetime=\"")
926 if templ_7745c5c3_Err != nil {958 if templ_7745c5c3_Err != nil {
927 return templ_7745c5c3_Err959 return templ_7745c5c3_Err
928 }960 }
929- var templ_7745c5c3_Var47 string961+ var templ_7745c5c3_Var49 string
930- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)962+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
931 if templ_7745c5c3_Err != nil {963 if templ_7745c5c3_Err != nil {
932- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 29}964+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 29}
933 }965 }
934- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)966+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
935 if templ_7745c5c3_Err != nil {967 if templ_7745c5c3_Err != nil {
936 return templ_7745c5c3_Err968 return templ_7745c5c3_Err
937 }969 }
938- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" title=\"")970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" title=\"")
939 if templ_7745c5c3_Err != nil {971 if templ_7745c5c3_Err != nil {
940 return templ_7745c5c3_Err972 return templ_7745c5c3_Err
941 }973 }
942- var templ_7745c5c3_Var48 string974+ var templ_7745c5c3_Var50 string
943- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)975+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
944 if templ_7745c5c3_Err != nil {976 if templ_7745c5c3_Err != nil {
945- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 48}977+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 48}
946 }978 }
947- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)979+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
948 if templ_7745c5c3_Err != nil {980 if templ_7745c5c3_Err != nil {
949 return templ_7745c5c3_Err981 return templ_7745c5c3_Err
950 }982 }
951- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">")
952 if templ_7745c5c3_Err != nil {984 if templ_7745c5c3_Err != nil {
953 return templ_7745c5c3_Err985 return templ_7745c5c3_Err
954 }986 }
955- var templ_7745c5c3_Var49 string987+ var templ_7745c5c3_Var51 string
956- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)988+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
957 if templ_7745c5c3_Err != nil {989 if templ_7745c5c3_Err != nil {
958- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 187, Col: 59}990+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 59}
959 }991 }
960- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))992+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
961 if templ_7745c5c3_Err != nil {993 if templ_7745c5c3_Err != nil {
962 return templ_7745c5c3_Err994 return templ_7745c5c3_Err
963 }995 }
964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</time>")996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</time>")
965 if templ_7745c5c3_Err != nil {997 if templ_7745c5c3_Err != nil {
966 return templ_7745c5c3_Err998 return templ_7745c5c3_Err
967 }999 }
968 }1000 }
969- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</td></tr>")1001+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</td></tr>")
970 if templ_7745c5c3_Err != nil {1002 if templ_7745c5c3_Err != nil {
971 return templ_7745c5c3_Err1003 return templ_7745c5c3_Err
972 }1004 }
@@ -990,12 +1022,12 @@ func refsRail(p RefsPage) templ.Component {
990 }()1022 }()
991 }1023 }
992 ctx = templ.InitializeContext(ctx)1024 ctx = templ.InitializeContext(ctx)
993- templ_7745c5c3_Var50 := templ.GetChildren(ctx)1025+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
994- if templ_7745c5c3_Var50 == nil {1026+ if templ_7745c5c3_Var52 == nil {
995- templ_7745c5c3_Var50 = templ.NopComponent1027+ templ_7745c5c3_Var52 = templ.NopComponent
996 }1028 }
997 ctx = templ.ClearChildren(ctx)1029 ctx = templ.ClearChildren(ctx)
998- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")1030+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
999 if templ_7745c5c3_Err != nil {1031 if templ_7745c5c3_Err != nil {
1000 return templ_7745c5c3_Err1032 return templ_7745c5c3_Err
1001 }1033 }
@@ -1003,7 +1035,7 @@ func refsRail(p RefsPage) templ.Component {
1003 if templ_7745c5c3_Err != nil {1035 if templ_7745c5c3_Err != nil {
1004 return templ_7745c5c3_Err1036 return templ_7745c5c3_Err
1005 }1037 }
1006- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</section>")1038+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</section>")
1007 if templ_7745c5c3_Err != nil {1039 if templ_7745c5c3_Err != nil {
1008 return templ_7745c5c3_Err1040 return templ_7745c5c3_Err
1009 }1041 }
@@ -1028,12 +1060,12 @@ func State(l Layout, p StatePage) templ.Component {
1028 }()1060 }()
1029 }1061 }
1030 ctx = templ.InitializeContext(ctx)1062 ctx = templ.InitializeContext(ctx)
1031- templ_7745c5c3_Var51 := templ.GetChildren(ctx)1063+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1032- if templ_7745c5c3_Var51 == nil {1064+ if templ_7745c5c3_Var53 == nil {
1033- templ_7745c5c3_Var51 = templ.NopComponent1065+ templ_7745c5c3_Var53 = templ.NopComponent
1034 }1066 }
1035 ctx = templ.ClearChildren(ctx)1067 ctx = templ.ClearChildren(ctx)
1036- templ_7745c5c3_Var52 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1068+ templ_7745c5c3_Var54 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1037 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1069 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1038 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1070 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1039 if !templ_7745c5c3_IsBuffer {1071 if !templ_7745c5c3_IsBuffer {
@@ -1045,51 +1077,51 @@ func State(l Layout, p StatePage) templ.Component {
1045 }()1077 }()
1046 }1078 }
1047 ctx = templ.InitializeContext(ctx)1079 ctx = templ.InitializeContext(ctx)
1048- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")1080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
1049 if templ_7745c5c3_Err != nil {1081 if templ_7745c5c3_Err != nil {
1050 return templ_7745c5c3_Err1082 return templ_7745c5c3_Err
1051 }1083 }
1052- var templ_7745c5c3_Var53 string1084+ var templ_7745c5c3_Var55 string
1053- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)1085+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
1054 if templ_7745c5c3_Err != nil {1086 if templ_7745c5c3_Err != nil {
1055- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 204, Col: 30}1087+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 30}
1056 }1088 }
1057- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1089+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1058 if templ_7745c5c3_Err != nil {1090 if templ_7745c5c3_Err != nil {
1059 return templ_7745c5c3_Err1091 return templ_7745c5c3_Err
1060 }1092 }
1061- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</p><h1 id=\"state-title\">")1093+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p><h1 id=\"state-title\">")
1062 if templ_7745c5c3_Err != nil {1094 if templ_7745c5c3_Err != nil {
1063 return templ_7745c5c3_Err1095 return templ_7745c5c3_Err
1064 }1096 }
1065- var templ_7745c5c3_Var54 string1097+ var templ_7745c5c3_Var56 string
1066- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1098+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1067 if templ_7745c5c3_Err != nil {1099 if templ_7745c5c3_Err != nil {
1068- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 205, Col: 33}1100+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 209, Col: 33}
1069 }1101 }
1070- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))1102+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1071 if templ_7745c5c3_Err != nil {1103 if templ_7745c5c3_Err != nil {
1072 return templ_7745c5c3_Err1104 return templ_7745c5c3_Err
1073 }1105 }
1074- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</h1>")1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</h1>")
1075 if templ_7745c5c3_Err != nil {1107 if templ_7745c5c3_Err != nil {
1076 return templ_7745c5c3_Err1108 return templ_7745c5c3_Err
1077 }1109 }
1078 for _, line := range p.Lines {1110 for _, line := range p.Lines {
1079- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"dim\">")1111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<p class=\"dim\">")
1080 if templ_7745c5c3_Err != nil {1112 if templ_7745c5c3_Err != nil {
1081 return templ_7745c5c3_Err1113 return templ_7745c5c3_Err
1082 }1114 }
1083- var templ_7745c5c3_Var55 string1115+ var templ_7745c5c3_Var57 string
1084- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(line)1116+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1085 if templ_7745c5c3_Err != nil {1117 if templ_7745c5c3_Err != nil {
1086- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 207, Col: 25}1118+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 211, Col: 25}
1087 }1119 }
1088- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))1120+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1089 if templ_7745c5c3_Err != nil {1121 if templ_7745c5c3_Err != nil {
1090 return templ_7745c5c3_Err1122 return templ_7745c5c3_Err
1091 }1123 }
1092- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p>")1124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</p>")
1093 if templ_7745c5c3_Err != nil {1125 if templ_7745c5c3_Err != nil {
1094 return templ_7745c5c3_Err1126 return templ_7745c5c3_Err
1095 }1127 }
@@ -1100,49 +1132,49 @@ func State(l Layout, p StatePage) templ.Component {
1100 return templ_7745c5c3_Err1132 return templ_7745c5c3_Err
1101 }1133 }
1102 }1134 }
1103- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"state-back\">")1135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<p class=\"state-back\">")
1104 if templ_7745c5c3_Err != nil {1136 if templ_7745c5c3_Err != nil {
1105 return templ_7745c5c3_Err1137 return templ_7745c5c3_Err
1106 }1138 }
1107 for _, b := range p.Back {1139 for _, b := range p.Back {
1108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<a href=\"")1140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<a href=\"")
1109 if templ_7745c5c3_Err != nil {1141 if templ_7745c5c3_Err != nil {
1110 return templ_7745c5c3_Err1142 return templ_7745c5c3_Err
1111 }1143 }
1112- var templ_7745c5c3_Var56 templ.SafeURL1144+ var templ_7745c5c3_Var58 templ.SafeURL
1113- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))1145+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
1114 if templ_7745c5c3_Err != nil {1146 if templ_7745c5c3_Err != nil {
1115- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 36}1147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 36}
1116 }1148 }
1117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1149+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1118 if templ_7745c5c3_Err != nil {1150 if templ_7745c5c3_Err != nil {
1119 return templ_7745c5c3_Err1151 return templ_7745c5c3_Err
1120 }1152 }
1121- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\">")1153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\">")
1122 if templ_7745c5c3_Err != nil {1154 if templ_7745c5c3_Err != nil {
1123 return templ_7745c5c3_Err1155 return templ_7745c5c3_Err
1124 }1156 }
1125- var templ_7745c5c3_Var57 string1157+ var templ_7745c5c3_Var59 string
1126- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)1158+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
1127 if templ_7745c5c3_Err != nil {1159 if templ_7745c5c3_Err != nil {
1128- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 48}1160+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 48}
1129 }1161 }
1130- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))1162+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1131 if templ_7745c5c3_Err != nil {1163 if templ_7745c5c3_Err != nil {
1132 return templ_7745c5c3_Err1164 return templ_7745c5c3_Err
1133 }1165 }
1134- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</a>")1166+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</a>")
1135 if templ_7745c5c3_Err != nil {1167 if templ_7745c5c3_Err != nil {
1136 return templ_7745c5c3_Err1168 return templ_7745c5c3_Err
1137 }1169 }
1138 }1170 }
1139- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</p></section>")1171+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p></section>")
1140 if templ_7745c5c3_Err != nil {1172 if templ_7745c5c3_Err != nil {
1141 return templ_7745c5c3_Err1173 return templ_7745c5c3_Err
1142 }1174 }
1143 return nil1175 return nil
1144 })1176 })
1145- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var52), templ_7745c5c3_Buffer)1177+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), templ_7745c5c3_Buffer)
1146 if templ_7745c5c3_Err != nil {1178 if templ_7745c5c3_Err != nil {
1147 return templ_7745c5c3_Err1179 return templ_7745c5c3_Err
1148 }1180 }
@@ -1171,12 +1203,12 @@ func Login(l Layout, p LoginPage) templ.Component {
1171 }()1203 }()
1172 }1204 }
1173 ctx = templ.InitializeContext(ctx)1205 ctx = templ.InitializeContext(ctx)
1174- templ_7745c5c3_Var58 := templ.GetChildren(ctx)1206+ templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1175- if templ_7745c5c3_Var58 == nil {1207+ if templ_7745c5c3_Var60 == nil {
1176- templ_7745c5c3_Var58 = templ.NopComponent1208+ templ_7745c5c3_Var60 = templ.NopComponent
1177 }1209 }
1178 ctx = templ.ClearChildren(ctx)1210 ctx = templ.ClearChildren(ctx)
1179- templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1211+ templ_7745c5c3_Var61 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1180 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1212 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1181 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1213 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1182 if !templ_7745c5c3_IsBuffer {1214 if !templ_7745c5c3_IsBuffer {
@@ -1188,75 +1220,75 @@ func Login(l Layout, p LoginPage) templ.Component {
1188 }()1220 }()
1189 }1221 }
1190 ctx = templ.InitializeContext(ctx)1222 ctx = templ.InitializeContext(ctx)
1191- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")1223+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1192 if templ_7745c5c3_Err != nil {1224 if templ_7745c5c3_Err != nil {
1193 return templ_7745c5c3_Err1225 return templ_7745c5c3_Err
1194 }1226 }
1195- var templ_7745c5c3_Var60 string1227+ var templ_7745c5c3_Var62 string
1196- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1228+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1197 if templ_7745c5c3_Err != nil {1229 if templ_7745c5c3_Err != nil {
1198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 33}1230+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 33}
1199 }1231 }
1200- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1232+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1201 if templ_7745c5c3_Err != nil {1233 if templ_7745c5c3_Err != nil {
1202 return templ_7745c5c3_Err1234 return templ_7745c5c3_Err
1203 }1235 }
1204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</h1>")1236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</h1>")
1205 if templ_7745c5c3_Err != nil {1237 if templ_7745c5c3_Err != nil {
1206 return templ_7745c5c3_Err1238 return templ_7745c5c3_Err
1207 }1239 }
1208 for _, line := range p.Lines {1240 for _, line := range p.Lines {
1209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<p class=\"dim\">")1241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"dim\">")
1210 if templ_7745c5c3_Err != nil {1242 if templ_7745c5c3_Err != nil {
1211 return templ_7745c5c3_Err1243 return templ_7745c5c3_Err
1212 }1244 }
1213- var templ_7745c5c3_Var61 string1245+ var templ_7745c5c3_Var63 string
1214- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(line)1246+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1215 if templ_7745c5c3_Err != nil {1247 if templ_7745c5c3_Err != nil {
1216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}1248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 25}
1217 }1249 }
1218- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))1250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1219 if templ_7745c5c3_Err != nil {1251 if templ_7745c5c3_Err != nil {
1220 return templ_7745c5c3_Err1252 return templ_7745c5c3_Err
1221 }1253 }
1222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</p>")1254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p>")
1223 if templ_7745c5c3_Err != nil {1255 if templ_7745c5c3_Err != nil {
1224 return templ_7745c5c3_Err1256 return templ_7745c5c3_Err
1225 }1257 }
1226 }1258 }
1227- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<form class=\"state-act\" method=\"post\" action=\"")1259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<form class=\"state-act\" method=\"post\" action=\"")
1228 if templ_7745c5c3_Err != nil {1260 if templ_7745c5c3_Err != nil {
1229 return templ_7745c5c3_Err1261 return templ_7745c5c3_Err
1230 }1262 }
1231- var templ_7745c5c3_Var62 templ.SafeURL1263+ var templ_7745c5c3_Var64 templ.SafeURL
1232- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))1264+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1233 if templ_7745c5c3_Err != nil {1265 if templ_7745c5c3_Err != nil {
1234- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 73}1266+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 237, Col: 73}
1235 }1267 }
1236- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))1268+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1237 if templ_7745c5c3_Err != nil {1269 if templ_7745c5c3_Err != nil {
1238 return templ_7745c5c3_Err1270 return templ_7745c5c3_Err
1239 }1271 }
1240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")1272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
1241 if templ_7745c5c3_Err != nil {1273 if templ_7745c5c3_Err != nil {
1242 return templ_7745c5c3_Err1274 return templ_7745c5c3_Err
1243 }1275 }
1244- var templ_7745c5c3_Var63 string1276+ var templ_7745c5c3_Var65 string
1245- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1277+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1246 if templ_7745c5c3_Err != nil {1278 if templ_7745c5c3_Err != nil {
1247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 234, Col: 56}1279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 238, Col: 56}
1248 }1280 }
1249- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))1281+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1250 if templ_7745c5c3_Err != nil {1282 if templ_7745c5c3_Err != nil {
1251 return templ_7745c5c3_Err1283 return templ_7745c5c3_Err
1252 }1284 }
1253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")1285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1254 if templ_7745c5c3_Err != nil {1286 if templ_7745c5c3_Err != nil {
1255 return templ_7745c5c3_Err1287 return templ_7745c5c3_Err
1256 }1288 }
1257 return nil1289 return nil
1258 })1290 })
1259- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer)1291+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var61), templ_7745c5c3_Buffer)
1260 if templ_7745c5c3_Err != nil {1292 if templ_7745c5c3_Err != nil {
1261 return templ_7745c5c3_Err1293 return templ_7745c5c3_Err
1262 }1294 }
@@ -1282,12 +1314,12 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1282 }()1314 }()
1283 }1315 }
1284 ctx = templ.InitializeContext(ctx)1316 ctx = templ.InitializeContext(ctx)
1285- templ_7745c5c3_Var64 := templ.GetChildren(ctx)1317+ templ_7745c5c3_Var66 := templ.GetChildren(ctx)
1286- if templ_7745c5c3_Var64 == nil {1318+ if templ_7745c5c3_Var66 == nil {
1287- templ_7745c5c3_Var64 = templ.NopComponent1319+ templ_7745c5c3_Var66 = templ.NopComponent
1288 }1320 }
1289 ctx = templ.ClearChildren(ctx)1321 ctx = templ.ClearChildren(ctx)
1290- templ_7745c5c3_Var65 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1322+ templ_7745c5c3_Var67 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1291 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1323 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1292 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1324 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1293 if !templ_7745c5c3_IsBuffer {1325 if !templ_7745c5c3_IsBuffer {
@@ -1299,88 +1331,88 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1299 }()1331 }()
1300 }1332 }
1301 ctx = templ.InitializeContext(ctx)1333 ctx = templ.InitializeContext(ctx)
1302- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")1334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1303 if templ_7745c5c3_Err != nil {1335 if templ_7745c5c3_Err != nil {
1304 return templ_7745c5c3_Err1336 return templ_7745c5c3_Err
1305 }1337 }
1306- var templ_7745c5c3_Var66 string1338+ var templ_7745c5c3_Var68 string
1307- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1339+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1308 if templ_7745c5c3_Err != nil {1340 if templ_7745c5c3_Err != nil {
1309- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 33}1341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 33}
1310 }1342 }
1311- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))1343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1312 if templ_7745c5c3_Err != nil {1344 if templ_7745c5c3_Err != nil {
1313 return templ_7745c5c3_Err1345 return templ_7745c5c3_Err
1314 }1346 }
1315- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")1347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
1316 if templ_7745c5c3_Err != nil {1348 if templ_7745c5c3_Err != nil {
1317 return templ_7745c5c3_Err1349 return templ_7745c5c3_Err
1318 }1350 }
1319- var templ_7745c5c3_Var67 string1351+ var templ_7745c5c3_Var69 string
1320- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1352+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1321 if templ_7745c5c3_Err != nil {1353 if templ_7745c5c3_Err != nil {
1322- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 87}1354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 87}
1323 }1355 }
1324- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)1356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1325 if templ_7745c5c3_Err != nil {1357 if templ_7745c5c3_Err != nil {
1326 return templ_7745c5c3_Err1358 return templ_7745c5c3_Err
1327 }1359 }
1328- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" data-finish=\"")1360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" data-finish=\"")
1329 if templ_7745c5c3_Err != nil {1361 if templ_7745c5c3_Err != nil {
1330 return templ_7745c5c3_Err1362 return templ_7745c5c3_Err
1331 }1363 }
1332- var templ_7745c5c3_Var68 string1364+ var templ_7745c5c3_Var70 string
1333- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1365+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1334 if templ_7745c5c3_Err != nil {1366 if templ_7745c5c3_Err != nil {
1335- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 112}1367+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 112}
1336 }1368 }
1337- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)1369+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
1338 if templ_7745c5c3_Err != nil {1370 if templ_7745c5c3_Err != nil {
1339 return templ_7745c5c3_Err1371 return templ_7745c5c3_Err
1340 }1372 }
1341- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\">")1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\">")
1342 if templ_7745c5c3_Err != nil {1374 if templ_7745c5c3_Err != nil {
1343 return templ_7745c5c3_Err1375 return templ_7745c5c3_Err
1344 }1376 }
1345- var templ_7745c5c3_Var69 string1377+ var templ_7745c5c3_Var71 string
1346- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1378+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1347 if templ_7745c5c3_Err != nil {1379 if templ_7745c5c3_Err != nil {
1348- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 125}1380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 125}
1349 }1381 }
1350- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))1382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1351 if templ_7745c5c3_Err != nil {1383 if templ_7745c5c3_Err != nil {
1352 return templ_7745c5c3_Err1384 return templ_7745c5c3_Err
1353 }1385 }
1354- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1355 if templ_7745c5c3_Err != nil {1387 if templ_7745c5c3_Err != nil {
1356 return templ_7745c5c3_Err1388 return templ_7745c5c3_Err
1357 }1389 }
1358 for _, line := range p.Lines {1390 for _, line := range p.Lines {
1359- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<p class=\"dim\">")1391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<p class=\"dim\">")
1360 if templ_7745c5c3_Err != nil {1392 if templ_7745c5c3_Err != nil {
1361 return templ_7745c5c3_Err1393 return templ_7745c5c3_Err
1362 }1394 }
1363- var templ_7745c5c3_Var70 string1395+ var templ_7745c5c3_Var72 string
1364- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(line)1396+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1365 if templ_7745c5c3_Err != nil {1397 if templ_7745c5c3_Err != nil {
1366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 25}1398+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 256, Col: 25}
1367 }1399 }
1368- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))1400+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
1369 if templ_7745c5c3_Err != nil {1401 if templ_7745c5c3_Err != nil {
1370 return templ_7745c5c3_Err1402 return templ_7745c5c3_Err
1371 }1403 }
1372- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</p>")1404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</p>")
1373 if templ_7745c5c3_Err != nil {1405 if templ_7745c5c3_Err != nil {
1374 return templ_7745c5c3_Err1406 return templ_7745c5c3_Err
1375 }1407 }
1376 }1408 }
1377- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")1409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1378 if templ_7745c5c3_Err != nil {1410 if templ_7745c5c3_Err != nil {
1379 return templ_7745c5c3_Err1411 return templ_7745c5c3_Err
1380 }1412 }
1381 return nil1413 return nil
1382 })1414 })
1383- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var65), templ_7745c5c3_Buffer)1415+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var67), templ_7745c5c3_Buffer)
1384 if templ_7745c5c3_Err != nil {1416 if templ_7745c5c3_Err != nil {
1385 return templ_7745c5c3_Err1417 return templ_7745c5c3_Err
1386 }1418 }
@@ -1405,12 +1437,12 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1405 }()1437 }()
1406 }1438 }
1407 ctx = templ.InitializeContext(ctx)1439 ctx = templ.InitializeContext(ctx)
1408- templ_7745c5c3_Var71 := templ.GetChildren(ctx)1440+ templ_7745c5c3_Var73 := templ.GetChildren(ctx)
1409- if templ_7745c5c3_Var71 == nil {1441+ if templ_7745c5c3_Var73 == nil {
1410- templ_7745c5c3_Var71 = templ.NopComponent1442+ templ_7745c5c3_Var73 = templ.NopComponent
1411 }1443 }
1412 ctx = templ.ClearChildren(ctx)1444 ctx = templ.ClearChildren(ctx)
1413- templ_7745c5c3_Var72 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1445+ templ_7745c5c3_Var74 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1414 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1446 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1415 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1447 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1416 if !templ_7745c5c3_IsBuffer {1448 if !templ_7745c5c3_IsBuffer {
@@ -1422,231 +1454,231 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1422 }()1454 }()
1423 }1455 }
1424 ctx = templ.InitializeContext(ctx)1456 ctx = templ.InitializeContext(ctx)
1425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")1457+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1426 if templ_7745c5c3_Err != nil {1458 if templ_7745c5c3_Err != nil {
1427 return templ_7745c5c3_Err1459 return templ_7745c5c3_Err
1428 }1460 }
1429- var templ_7745c5c3_Var73 string1461+ var templ_7745c5c3_Var75 string
1430- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))1462+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1431 if templ_7745c5c3_Err != nil {1463 if templ_7745c5c3_Err != nil {
1432- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 262, Col: 81}1464+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 266, Col: 81}
1433 }1465 }
1434- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))1466+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1435 if templ_7745c5c3_Err != nil {1467 if templ_7745c5c3_Err != nil {
1436 return templ_7745c5c3_Err1468 return templ_7745c5c3_Err
1437 }1469 }
1438- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
1439 if templ_7745c5c3_Err != nil {1471 if templ_7745c5c3_Err != nil {
1440 return templ_7745c5c3_Err1472 return templ_7745c5c3_Err
1441 }1473 }
1442 if len(p.Rows) > 0 {1474 if len(p.Rows) > 0 {
1443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1444 if templ_7745c5c3_Err != nil {1476 if templ_7745c5c3_Err != nil {
1445 return templ_7745c5c3_Err1477 return templ_7745c5c3_Err
1446 }1478 }
1447 for _, r := range p.Rows {1479 for _, r := range p.Rows {
1448- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<tr><td class=\"c-name\">")1480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<tr><td class=\"c-name\">")
1449 if templ_7745c5c3_Err != nil {1481 if templ_7745c5c3_Err != nil {
1450 return templ_7745c5c3_Err1482 return templ_7745c5c3_Err
1451 }1483 }
1452- var templ_7745c5c3_Var74 string1484+ var templ_7745c5c3_Var76 string
1453- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1485+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1454 if templ_7745c5c3_Err != nil {1486 if templ_7745c5c3_Err != nil {
1455- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 34}1487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 282, Col: 34}
1456 }1488 }
1457- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))1489+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1458 if templ_7745c5c3_Err != nil {1490 if templ_7745c5c3_Err != nil {
1459 return templ_7745c5c3_Err1491 return templ_7745c5c3_Err
1460 }1492 }
1461- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-when nowrap\"><time")1493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</td><td class=\"c-when nowrap\"><time")
1462 if templ_7745c5c3_Err != nil {1494 if templ_7745c5c3_Err != nil {
1463 return templ_7745c5c3_Err1495 return templ_7745c5c3_Err
1464 }1496 }
1465 if r.CreatedAt != "" {1497 if r.CreatedAt != "" {
1466- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " datetime=\"")1498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " datetime=\"")
1467 if templ_7745c5c3_Err != nil {1499 if templ_7745c5c3_Err != nil {
1468 return templ_7745c5c3_Err1500 return templ_7745c5c3_Err
1469 }1501 }
1470- var templ_7745c5c3_Var75 string1502+ var templ_7745c5c3_Var77 string
1471- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)1503+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1472 if templ_7745c5c3_Err != nil {1504 if templ_7745c5c3_Err != nil {
1473- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 282, Col: 32}1505+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 32}
1474 }1506 }
1475- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)1507+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1476 if templ_7745c5c3_Err != nil {1508 if templ_7745c5c3_Err != nil {
1477 return templ_7745c5c3_Err1509 return templ_7745c5c3_Err
1478 }1510 }
1479- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"")1511+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\"")
1480 if templ_7745c5c3_Err != nil {1512 if templ_7745c5c3_Err != nil {
1481 return templ_7745c5c3_Err1513 return templ_7745c5c3_Err
1482 }1514 }
1483 }1515 }
1484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, ">")1516+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, ">")
1485 if templ_7745c5c3_Err != nil {1517 if templ_7745c5c3_Err != nil {
1486 return templ_7745c5c3_Err1518 return templ_7745c5c3_Err
1487 }1519 }
1488- var templ_7745c5c3_Var76 string1520+ var templ_7745c5c3_Var78 string
1489- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)1521+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1490 if templ_7745c5c3_Err != nil {1522 if templ_7745c5c3_Err != nil {
1491- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 284, Col: 20}1523+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 288, Col: 20}
1492 }1524 }
1493- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))1525+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1494 if templ_7745c5c3_Err != nil {1526 if templ_7745c5c3_Err != nil {
1495 return templ_7745c5c3_Err1527 return templ_7745c5c3_Err
1496 }1528 }
1497- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</time></td><td class=\"c-when nowrap\"><time")1529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</time></td><td class=\"c-when nowrap\"><time")
1498 if templ_7745c5c3_Err != nil {1530 if templ_7745c5c3_Err != nil {
1499 return templ_7745c5c3_Err1531 return templ_7745c5c3_Err
1500 }1532 }
1501 if r.LastUsedAt != "" {1533 if r.LastUsedAt != "" {
1502- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " datetime=\"")1534+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, " datetime=\"")
1503 if templ_7745c5c3_Err != nil {1535 if templ_7745c5c3_Err != nil {
1504 return templ_7745c5c3_Err1536 return templ_7745c5c3_Err
1505 }1537 }
1506- var templ_7745c5c3_Var77 string1538+ var templ_7745c5c3_Var79 string
1507- templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)1539+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1508 if templ_7745c5c3_Err != nil {1540 if templ_7745c5c3_Err != nil {
1509- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 33}1541+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 293, Col: 33}
1510 }1542 }
1511- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)1543+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1512 if templ_7745c5c3_Err != nil {1544 if templ_7745c5c3_Err != nil {
1513 return templ_7745c5c3_Err1545 return templ_7745c5c3_Err
1514 }1546 }
1515- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\"")1547+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\"")
1516 if templ_7745c5c3_Err != nil {1548 if templ_7745c5c3_Err != nil {
1517 return templ_7745c5c3_Err1549 return templ_7745c5c3_Err
1518 }1550 }
1519 }1551 }
1520- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, ">")1552+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, ">")
1521 if templ_7745c5c3_Err != nil {1553 if templ_7745c5c3_Err != nil {
1522 return templ_7745c5c3_Err1554 return templ_7745c5c3_Err
1523 }1555 }
1524- var templ_7745c5c3_Var78 string1556+ var templ_7745c5c3_Var80 string
1525- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)1557+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1526 if templ_7745c5c3_Err != nil {1558 if templ_7745c5c3_Err != nil {
1527- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 291, Col: 21}1559+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 295, Col: 21}
1528 }1560 }
1529- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))1561+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
1530 if templ_7745c5c3_Err != nil {1562 if templ_7745c5c3_Err != nil {
1531 return templ_7745c5c3_Err1563 return templ_7745c5c3_Err
1532 }1564 }
1533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</time></td><td class=\"c-sync\">")1565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</time></td><td class=\"c-sync\">")
1534 if templ_7745c5c3_Err != nil {1566 if templ_7745c5c3_Err != nil {
1535 return templ_7745c5c3_Err1567 return templ_7745c5c3_Err
1536 }1568 }
1537 if r.Synced {1569 if r.Synced {
1538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "yes")1570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "yes")
1539 if templ_7745c5c3_Err != nil {1571 if templ_7745c5c3_Err != nil {
1540 return templ_7745c5c3_Err1572 return templ_7745c5c3_Err
1541 }1573 }
1542 } else {1574 } else {
1543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "no")1575+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "no")
1544 if templ_7745c5c3_Err != nil {1576 if templ_7745c5c3_Err != nil {
1545 return templ_7745c5c3_Err1577 return templ_7745c5c3_Err
1546 }1578 }
1547 }1579 }
1548- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td><td class=\"c-act\"><form method=\"post\" action=\"")1580+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1549 if templ_7745c5c3_Err != nil {1581 if templ_7745c5c3_Err != nil {
1550 return templ_7745c5c3_Err1582 return templ_7745c5c3_Err
1551 }1583 }
1552- var templ_7745c5c3_Var79 templ.SafeURL1584+ var templ_7745c5c3_Var81 templ.SafeURL
1553- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))1585+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1554 if templ_7745c5c3_Err != nil {1586 if templ_7745c5c3_Err != nil {
1555- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 301, Col: 60}1587+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 60}
1556 }1588 }
1557- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))1589+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1558 if templ_7745c5c3_Err != nil {1590 if templ_7745c5c3_Err != nil {
1559 return templ_7745c5c3_Err1591 return templ_7745c5c3_Err
1560 }1592 }
1561- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")1593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1562 if templ_7745c5c3_Err != nil {1594 if templ_7745c5c3_Err != nil {
1563 return templ_7745c5c3_Err1595 return templ_7745c5c3_Err
1564 }1596 }
1565- var templ_7745c5c3_Var80 string1597+ var templ_7745c5c3_Var82 string
1566- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)1598+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1567 if templ_7745c5c3_Err != nil {1599 if templ_7745c5c3_Err != nil {
1568- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 302, Col: 89}1600+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 306, Col: 89}
1569 }1601 }
1570- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)1602+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1571 if templ_7745c5c3_Err != nil {1603 if templ_7745c5c3_Err != nil {
1572 return templ_7745c5c3_Err1604 return templ_7745c5c3_Err
1573 }1605 }
1574- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\">Remove</button></form></td></tr>")1606+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">Remove</button></form></td></tr>")
1575 if templ_7745c5c3_Err != nil {1607 if templ_7745c5c3_Err != nil {
1576 return templ_7745c5c3_Err1608 return templ_7745c5c3_Err
1577 }1609 }
1578 }1610 }
1579- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</tbody></table>")1611+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</tbody></table>")
1580 if templ_7745c5c3_Err != nil {1612 if templ_7745c5c3_Err != nil {
1581 return templ_7745c5c3_Err1613 return templ_7745c5c3_Err
1582 }1614 }
1583 } else {1615 } else {
1584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"lede dim\">no passkeys yet.</p>")1616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<p class=\"lede dim\">no passkeys yet.</p>")
1585 if templ_7745c5c3_Err != nil {1617 if templ_7745c5c3_Err != nil {
1586 return templ_7745c5c3_Err1618 return templ_7745c5c3_Err
1587 }1619 }
1588 }1620 }
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " ")1621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, " ")
1590 if templ_7745c5c3_Err != nil {1622 if templ_7745c5c3_Err != nil {
1591 return templ_7745c5c3_Err1623 return templ_7745c5c3_Err
1592 }1624 }
1593 if p.Full {1625 if p.Full {
1594- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"dim\">")1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<p class=\"dim\">")
1595 if templ_7745c5c3_Err != nil {1627 if templ_7745c5c3_Err != nil {
1596 return templ_7745c5c3_Err1628 return templ_7745c5c3_Err
1597 }1629 }
1598- var templ_7745c5c3_Var81 string1630+ var templ_7745c5c3_Var83 string
1599- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))1631+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1600 if templ_7745c5c3_Err != nil {1632 if templ_7745c5c3_Err != nil {
1601- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 313, Col: 31}1633+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 317, Col: 31}
1602 }1634 }
1603- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))1635+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1604 if templ_7745c5c3_Err != nil {1636 if templ_7745c5c3_Err != nil {
1605 return templ_7745c5c3_Err1637 return templ_7745c5c3_Err
1606 }1638 }
1607- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, " passkeys are registered, the most there can be. Remove one to add another.</p>")1639+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1608 if templ_7745c5c3_Err != nil {1640 if templ_7745c5c3_Err != nil {
1609 return templ_7745c5c3_Err1641 return templ_7745c5c3_Err
1610 }1642 }
1611 } else {1643 } else {
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"")1644+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"")
1613 if templ_7745c5c3_Err != nil {1645 if templ_7745c5c3_Err != nil {
1614 return templ_7745c5c3_Err1646 return templ_7745c5c3_Err
1615 }1647 }
1616- var templ_7745c5c3_Var82 string1648+ var templ_7745c5c3_Var84 string
1617- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1649+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1618 if templ_7745c5c3_Err != nil {1650 if templ_7745c5c3_Err != nil {
1619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 318, Col: 90}1651+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 322, Col: 90}
1620 }1652 }
1621- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)1653+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
1622 if templ_7745c5c3_Err != nil {1654 if templ_7745c5c3_Err != nil {
1623 return templ_7745c5c3_Err1655 return templ_7745c5c3_Err
1624 }1656 }
1625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\" data-finish=\"")1657+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" data-finish=\"")
1626 if templ_7745c5c3_Err != nil {1658 if templ_7745c5c3_Err != nil {
1627 return templ_7745c5c3_Err1659 return templ_7745c5c3_Err
1628 }1660 }
1629- var templ_7745c5c3_Var83 string1661+ var templ_7745c5c3_Var85 string
1630- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1662+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1631 if templ_7745c5c3_Err != nil {1663 if templ_7745c5c3_Err != nil {
1632- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 318, Col: 115}1664+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 322, Col: 115}
1633 }1665 }
1634- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)1666+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
1635 if templ_7745c5c3_Err != nil {1667 if templ_7745c5c3_Err != nil {
1636 return templ_7745c5c3_Err1668 return templ_7745c5c3_Err
1637 }1669 }
1638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">Add a passkey</button></form>")1670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">Add a passkey</button></form>")
1639 if templ_7745c5c3_Err != nil {1671 if templ_7745c5c3_Err != nil {
1640 return templ_7745c5c3_Err1672 return templ_7745c5c3_Err
1641 }1673 }
1642 }1674 }
1643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")1675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1644 if templ_7745c5c3_Err != nil {1676 if templ_7745c5c3_Err != nil {
1645 return templ_7745c5c3_Err1677 return templ_7745c5c3_Err
1646 }1678 }
1647 return nil1679 return nil
1648 })1680 })
1649- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var72), templ_7745c5c3_Buffer)1681+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var74), templ_7745c5c3_Buffer)
1650 if templ_7745c5c3_Err != nil {1682 if templ_7745c5c3_Err != nil {
1651 return templ_7745c5c3_Err1683 return templ_7745c5c3_Err
1652 }1684 }
M internal/adapters/web/pathcache.go +16 -6
@@ -70,6 +70,14 @@ func (c *pathCache) getLocked(id string) ([]byte, bool) {
7070 return el.Value.(*pathEntry).body, true
7171 }
7272
73+// entryOverhead is what an entry costs besides its id and body: the list
74+// element, the entry and the map slot. Counted so that tiny bodies (a cached
75+// "no license") cannot pile up far past the budget.
76+const entryOverhead = 128
77+
78+// cost is what an entry counts against the budget.
79+func (e *pathEntry) cost() int { return len(e.id) + len(e.body) + entryOverhead }
80+
7381 // putLocked stores a list, evicting the least recently used ones to stay in
7482 // budget. A list over a quarter of the budget is not stored.
7583 func (c *pathCache) putLocked(id string, body []byte) {
@@ -77,20 +85,22 @@ func (c *pathCache) putLocked(id string, body []byte) {
7785 return
7886 }
7987 if el, ok := c.byID[id]; ok {
80- c.size -= len(el.Value.(*pathEntry).body)
81- el.Value.(*pathEntry).body = body
82- c.size += len(body)
88+ e := el.Value.(*pathEntry)
89+ c.size -= e.cost()
90+ e.body = body
91+ c.size += e.cost()
8392 c.lru.MoveToFront(el)
8493 } else {
85- c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})
86- c.size += len(body)
94+ e := &pathEntry{id: id, body: body}
95+ c.byID[id] = c.lru.PushFront(e)
96+ c.size += e.cost()
8797 }
8898 for c.size > c.budget {
8999 el := c.lru.Back()
90100 e := el.Value.(*pathEntry)
91101 c.lru.Remove(el)
92102 delete(c.byID, e.id)
93- c.size -= len(e.body)
103+ c.size -= e.cost()
94104 }
95105 }
96106
@@ -70,6 +70,14 @@ func (c *pathCache) getLocked(id string) ([]byte, bool) {
70 return el.Value.(*pathEntry).body, true70 return el.Value.(*pathEntry).body, true
71 }71 }
72 72
73+// entryOverhead is what an entry costs besides its id and body: the list
74+// element, the entry and the map slot. Counted so that tiny bodies (a cached
75+// "no license") cannot pile up far past the budget.
76+const entryOverhead = 128
77+
78+// cost is what an entry counts against the budget.
79+func (e *pathEntry) cost() int { return len(e.id) + len(e.body) + entryOverhead }
80+
73 // putLocked stores a list, evicting the least recently used ones to stay in81 // putLocked stores a list, evicting the least recently used ones to stay in
74 // budget. A list over a quarter of the budget is not stored.82 // budget. A list over a quarter of the budget is not stored.
75 func (c *pathCache) putLocked(id string, body []byte) {83 func (c *pathCache) putLocked(id string, body []byte) {
@@ -77,20 +85,22 @@ func (c *pathCache) putLocked(id string, body []byte) {
77 return85 return
78 }86 }
79 if el, ok := c.byID[id]; ok {87 if el, ok := c.byID[id]; ok {
80- c.size -= len(el.Value.(*pathEntry).body)88+ e := el.Value.(*pathEntry)
81- el.Value.(*pathEntry).body = body89+ c.size -= e.cost()
82- c.size += len(body)90+ e.body = body
91+ c.size += e.cost()
83 c.lru.MoveToFront(el)92 c.lru.MoveToFront(el)
84 } else {93 } else {
85- c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})94+ e := &pathEntry{id: id, body: body}
86- c.size += len(body)95+ c.byID[id] = c.lru.PushFront(e)
96+ c.size += e.cost()
87 }97 }
88 for c.size > c.budget {98 for c.size > c.budget {
89 el := c.lru.Back()99 el := c.lru.Back()
90 e := el.Value.(*pathEntry)100 e := el.Value.(*pathEntry)
91 c.lru.Remove(el)101 c.lru.Remove(el)
92 delete(c.byID, e.id)102 delete(c.byID, e.id)
93- c.size -= len(e.body)103+ c.size -= e.cost()
94 }104 }
95 }105 }
96 106
M internal/adapters/web/pathcache_test.go +34 -17
@@ -30,55 +30,72 @@ func (c *pathCache) has(id string) bool {
3030 return ok
3131 }
3232
33+// entry is the budget an entry of a one-letter id and an n-byte body takes.
34+func entry(n int) int { return 1 + n + entryOverhead }
35+
3336 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
3437 t.Parallel()
35- c := newPathCache(100, 1)
38+ budget := 4 * entry(100)
39+ c := newPathCache(budget, 1)
3640 blob := func(n int) []byte { return make([]byte, n) }
37- c.store("a", blob(25))
38- c.store("b", blob(25))
39- c.store("e", blob(25))
41+ c.store("a", blob(100))
42+ c.store("b", blob(100))
43+ c.store("e", blob(100))
4044 require.True(t, c.has("a")) // a is now the most recent
41- c.store("c", blob(25)) // 100 fit
42- c.store("d", blob(10)) // 110 > 100: b, the least recent, goes
45+ c.store("c", blob(100)) // four fit
46+ c.store("d", blob(10)) // one more: b, the least recent, goes
4347 assert.False(t, c.has("b"), "b was evicted")
4448 assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))
45- assert.Equal(t, 85, c.size)
49+ assert.Equal(t, 3*entry(100)+entry(10), c.size)
4650
4751 // Replacing an entry accounts for the old size.
4852 c.store("a", blob(10))
49- assert.Equal(t, 70, c.size)
53+ assert.Equal(t, 2*entry(100)+2*entry(10), c.size)
5054
5155 // A big one pushes out as many as needed.
52- c.store("f", blob(25))
53- c.store("g", blob(25))
54- assert.LessOrEqual(t, c.size, 100)
56+ c.store("f", blob(100))
57+ c.store("g", blob(100))
58+ assert.LessOrEqual(t, c.size, budget)
5559 assert.True(t, c.has("g"))
5660 }
5761
62+// Tiny bodies (a cached "no license") count their id and overhead too, so
63+// their number stays bounded by the budget.
64+func TestPathCacheBoundsTinyEntries(t *testing.T) {
65+ t.Parallel()
66+ c := newPathCache(1<<12, 1)
67+ for i := range 10_000 {
68+ c.store(fmt.Sprintf("repo\x00%040d", i), []byte("null"))
69+ }
70+ assert.LessOrEqual(t, c.size, 1<<12)
71+ assert.Less(t, len(c.byID), (1<<12)/entryOverhead+1)
72+ assert.Equal(t, len(c.byID), c.lru.Len())
73+}
74+
5875 // A list over a quarter of the budget is not kept, since it would push out the
5976 // others; one at the limit is.
6077 func TestPathCacheSkipsOversizedEntries(t *testing.T) {
6178 t.Parallel()
62- c := newPathCache(100, 1)
79+ c := newPathCache(1000, 1)
6380 var builds atomic.Int32
6481 big := func(n int) func() ([]byte, error) {
6582 return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }
6683 }
6784 for range 2 {
68- b, err := c.load(context.Background(), "r", "big", big(26))
85+ b, err := c.load(context.Background(), "r", "big", big(251))
6986 require.NoError(t, err)
70- assert.Len(t, b, 26, "the caller still gets the list")
87+ assert.Len(t, b, 251, "the caller still gets the list")
7188 }
7289 assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")
7390 assert.False(t, c.has("big"))
7491 assert.Zero(t, c.size)
7592
7693 for range 2 {
77- _, err := c.load(context.Background(), "r", "fits", big(25))
94+ _, err := c.load(context.Background(), "r", "fits", big(250))
7895 require.NoError(t, err)
7996 }
8097 assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")
81- assert.Equal(t, 25, c.size)
98+ assert.Equal(t, len("fits")+250+entryOverhead, c.size)
8299 }
83100
84101 func TestPathCacheConcurrent(t *testing.T) {
@@ -99,7 +116,7 @@ func TestPathCacheConcurrent(t *testing.T) {
99116 assert.LessOrEqual(t, c.size, 1000)
100117 sum := 0
101118 for _, el := range c.byID {
102- sum += len(el.Value.(*pathEntry).body)
119+ sum += el.Value.(*pathEntry).cost()
103120 }
104121 assert.Equal(t, sum, c.size, "the size is the sum of the entries")
105122 assert.Equal(t, len(c.byID), c.lru.Len())
@@ -30,55 +30,72 @@ func (c *pathCache) has(id string) bool {
30 return ok30 return ok
31 }31 }
32 32
33+// entry is the budget an entry of a one-letter id and an n-byte body takes.
34+func entry(n int) int { return 1 + n + entryOverhead }
35+
33 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {36 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
34 t.Parallel()37 t.Parallel()
35- c := newPathCache(100, 1)38+ budget := 4 * entry(100)
39+ c := newPathCache(budget, 1)
36 blob := func(n int) []byte { return make([]byte, n) }40 blob := func(n int) []byte { return make([]byte, n) }
37- c.store("a", blob(25))41+ c.store("a", blob(100))
38- c.store("b", blob(25))42+ c.store("b", blob(100))
39- c.store("e", blob(25))43+ c.store("e", blob(100))
40 require.True(t, c.has("a")) // a is now the most recent44 require.True(t, c.has("a")) // a is now the most recent
41- c.store("c", blob(25)) // 100 fit45+ c.store("c", blob(100)) // four fit
42- c.store("d", blob(10)) // 110 > 100: b, the least recent, goes46+ c.store("d", blob(10)) // one more: b, the least recent, goes
43 assert.False(t, c.has("b"), "b was evicted")47 assert.False(t, c.has("b"), "b was evicted")
44 assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))48 assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))
45- assert.Equal(t, 85, c.size)49+ assert.Equal(t, 3*entry(100)+entry(10), c.size)
46 50
47 // Replacing an entry accounts for the old size.51 // Replacing an entry accounts for the old size.
48 c.store("a", blob(10))52 c.store("a", blob(10))
49- assert.Equal(t, 70, c.size)53+ assert.Equal(t, 2*entry(100)+2*entry(10), c.size)
50 54
51 // A big one pushes out as many as needed.55 // A big one pushes out as many as needed.
52- c.store("f", blob(25))56+ c.store("f", blob(100))
53- c.store("g", blob(25))57+ c.store("g", blob(100))
54- assert.LessOrEqual(t, c.size, 100)58+ assert.LessOrEqual(t, c.size, budget)
55 assert.True(t, c.has("g"))59 assert.True(t, c.has("g"))
56 }60 }
57 61
62+// Tiny bodies (a cached "no license") count their id and overhead too, so
63+// their number stays bounded by the budget.
64+func TestPathCacheBoundsTinyEntries(t *testing.T) {
65+ t.Parallel()
66+ c := newPathCache(1<<12, 1)
67+ for i := range 10_000 {
68+ c.store(fmt.Sprintf("repo\x00%040d", i), []byte("null"))
69+ }
70+ assert.LessOrEqual(t, c.size, 1<<12)
71+ assert.Less(t, len(c.byID), (1<<12)/entryOverhead+1)
72+ assert.Equal(t, len(c.byID), c.lru.Len())
73+}
74+
58 // A list over a quarter of the budget is not kept, since it would push out the75 // A list over a quarter of the budget is not kept, since it would push out the
59 // others; one at the limit is.76 // others; one at the limit is.
60 func TestPathCacheSkipsOversizedEntries(t *testing.T) {77 func TestPathCacheSkipsOversizedEntries(t *testing.T) {
61 t.Parallel()78 t.Parallel()
62- c := newPathCache(100, 1)79+ c := newPathCache(1000, 1)
63 var builds atomic.Int3280 var builds atomic.Int32
64 big := func(n int) func() ([]byte, error) {81 big := func(n int) func() ([]byte, error) {
65 return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }82 return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }
66 }83 }
67 for range 2 {84 for range 2 {
68- b, err := c.load(context.Background(), "r", "big", big(26))85+ b, err := c.load(context.Background(), "r", "big", big(251))
69 require.NoError(t, err)86 require.NoError(t, err)
70- assert.Len(t, b, 26, "the caller still gets the list")87+ assert.Len(t, b, 251, "the caller still gets the list")
71 }88 }
72 assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")89 assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")
73 assert.False(t, c.has("big"))90 assert.False(t, c.has("big"))
74 assert.Zero(t, c.size)91 assert.Zero(t, c.size)
75 92
76 for range 2 {93 for range 2 {
77- _, err := c.load(context.Background(), "r", "fits", big(25))94+ _, err := c.load(context.Background(), "r", "fits", big(250))
78 require.NoError(t, err)95 require.NoError(t, err)
79 }96 }
80 assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")97 assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")
81- assert.Equal(t, 25, c.size)98+ assert.Equal(t, len("fits")+250+entryOverhead, c.size)
82 }99 }
83 100
84 func TestPathCacheConcurrent(t *testing.T) {101 func TestPathCacheConcurrent(t *testing.T) {
@@ -99,7 +116,7 @@ func TestPathCacheConcurrent(t *testing.T) {
99 assert.LessOrEqual(t, c.size, 1000)116 assert.LessOrEqual(t, c.size, 1000)
100 sum := 0117 sum := 0
101 for _, el := range c.byID {118 for _, el := range c.byID {
102- sum += len(el.Value.(*pathEntry).body)119+ sum += el.Value.(*pathEntry).cost()
103 }120 }
104 assert.Equal(t, sum, c.size, "the size is the sum of the entries")121 assert.Equal(t, sum, c.size, "the size is the sum of the entries")
105 assert.Equal(t, len(c.byID), c.lru.Len())122 assert.Equal(t, len(c.byID), c.lru.Len())
M internal/adapters/web/static/css/00-tokens.css +1 -0
@@ -78,6 +78,7 @@
7878 --fs-code: 13px;
7979 --fs-s: 13px;
8080 --fs-xs: 12px;
81+ --fs-2xs: 11px;
8182 --lh: 24px;
8283 --lh-code: 20px;
8384
@@ -78,6 +78,7 @@
78 --fs-code: 13px;78 --fs-code: 13px;
79 --fs-s: 13px;79 --fs-s: 13px;
80 --fs-xs: 12px;80 --fs-xs: 12px;
81+ --fs-2xs: 11px;
81 --lh: 24px;82 --lh: 24px;
82 --lh-code: 20px;83 --lh-code: 20px;
83 84
M internal/adapters/web/static/css/20-shell.css +6 -2
@@ -29,15 +29,19 @@
2929 .i-cards { --i: url(icons/cards.svg); }
3030 .i-split { --i: url(icons/split.svg); }
3131 .i-compact { --i: url(icons/compact.svg); }
32+ .i-refresh-cw { --i: url(icons/refresh-cw.svg); }
3233 .i-mailbox { --i: url(icons/mailbox.svg); }
3334 .i-sun-moon { --i: url(icons/sun-moon.svg); }
3435 .i-sun { --i: url(icons/sun.svg); }
3536 .i-moon { --i: url(icons/moon.svg); }
3637
3738 .pill {
38- display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
39- font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
39+ display: inline-flex; align-items: center; height: 18px; padding: 0 var(--s2); margin-left: var(--s1);
40+ font: 600 var(--fs-2xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
4041 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
42+ /* a link pill: no underline; the border lightens on hover and focus */
43+ &:is(a) { text-decoration: none; cursor: pointer; transition: border-color .12s; }
44+ &:is(a):hover, &:is(a):focus-visible { text-decoration: none; border-color: var(--ink-dim); }
4145 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
4246 &.new { color: var(--teal); border-color: currentColor; }
4347 /* the signature of a commit: the protocol, then the verdict, always as text
@@ -29,15 +29,19 @@
29 .i-cards { --i: url(icons/cards.svg); }29 .i-cards { --i: url(icons/cards.svg); }
30 .i-split { --i: url(icons/split.svg); }30 .i-split { --i: url(icons/split.svg); }
31 .i-compact { --i: url(icons/compact.svg); }31 .i-compact { --i: url(icons/compact.svg); }
32+ .i-refresh-cw { --i: url(icons/refresh-cw.svg); }
32 .i-mailbox { --i: url(icons/mailbox.svg); }33 .i-mailbox { --i: url(icons/mailbox.svg); }
33 .i-sun-moon { --i: url(icons/sun-moon.svg); }34 .i-sun-moon { --i: url(icons/sun-moon.svg); }
34 .i-sun { --i: url(icons/sun.svg); }35 .i-sun { --i: url(icons/sun.svg); }
35 .i-moon { --i: url(icons/moon.svg); }36 .i-moon { --i: url(icons/moon.svg); }
36 37
37 .pill {38 .pill {
38- display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);39+ display: inline-flex; align-items: center; height: 18px; padding: 0 var(--s2); margin-left: var(--s1);
39- font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;40+ font: 600 var(--fs-2xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
40 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;41 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
42+ /* a link pill: no underline; the border lightens on hover and focus */
43+ &:is(a) { text-decoration: none; cursor: pointer; transition: border-color .12s; }
44+ &:is(a):hover, &:is(a):focus-visible { text-decoration: none; border-color: var(--ink-dim); }
41 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }45 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
42 &.new { color: var(--teal); border-color: currentColor; }46 &.new { color: var(--teal); border-color: currentColor; }
43 /* the signature of a commit: the protocol, then the verdict, always as text47 /* the signature of a commit: the protocol, then the verdict, always as text
M internal/adapters/web/static/css/30-list.css +10 -10
@@ -69,8 +69,12 @@
6969 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
7070 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
7171 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
72- & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
72+ & .repo:hover .repo-name a:not(.pill), & .repo[data-cursor] .repo-name a:not(.pill) { text-decoration: underline; }
7373 & .repo-name .pill { margin-left: 0; }
74+ & .repo-name a.pill { display: block; line-height: 18px; min-width: 0; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; }
75+ & .repo-name .repo-release { color: var(--accent); }
76+ & .repo-name .repo-license { color: var(--ink-dim); }
77+ & .repo-activity { display: inline-flex; align-items: center; flex: none; color: var(--accent); }
7478 /* Every card has the same height: two lines of description, one of topics
7579 (reserved when there are none), five of commits. */
7680 & .repo-desc {
@@ -123,7 +127,7 @@
123127 & .repo-stats dd a { color: inherit; text-decoration: none; }
124128 & .repo-stats dd a:hover { text-decoration: underline; }
125129 & .repo-ci {
126- flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;
130+ flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px;
127131 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
128132 }
129133 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
@@ -170,7 +174,7 @@
170174 }
171175 & .repo-main, & .repo-head { display: contents; }
172176 & .repo-name { grid-area: name; align-self: center; font-size: 16px; }
173- & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; }
177+ & .repo-ci { grid-area: ci; align-self: center; }
174178 & .repo-desc { grid-area: desc; margin-top: var(--s2); }
175179 & .repo-topics { grid-area: topics; margin-top: var(--s2); }
176180 & .repo-topics:empty { display: none; }
@@ -212,7 +216,8 @@
212216 /* min-width 0: a long name is cut, it does not widen the column past its cap */
213217 & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
214218 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
215- & .repo-name .pill { flex: none; }
219+ & .repo-name .pill { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
220+ & .repo-name a.pill { max-width: 12ch; }
216221 & .repo-desc {
217222 grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
218223 font-size: var(--fs-s); color: var(--ink-mute);
@@ -227,13 +232,8 @@
227232 position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
228233 }
229234 & .repo-stats .rs-tag dd { max-width: 10ch; }
230- & .repo-stats .rs-tag dd::before {
231- content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
232- background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
233- }
234235 /* the CI state is its mark; the word is in the tooltip and for screen readers */
235- & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; }
236- & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
236+ & .repo-ci { grid-area: ci; justify-self: center; }
237237 /* the pulse: the same curve, small, with a bigger dot to stay seen */
238238 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
239239 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
@@ -69,8 +69,12 @@
69 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }69 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
70 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }70 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
71 & .repo-name a { color: var(--ink-hi); text-decoration: none; }71 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
72- & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }72+ & .repo:hover .repo-name a:not(.pill), & .repo[data-cursor] .repo-name a:not(.pill) { text-decoration: underline; }
73 & .repo-name .pill { margin-left: 0; }73 & .repo-name .pill { margin-left: 0; }
74+ & .repo-name a.pill { display: block; line-height: 18px; min-width: 0; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; }
75+ & .repo-name .repo-release { color: var(--accent); }
76+ & .repo-name .repo-license { color: var(--ink-dim); }
77+ & .repo-activity { display: inline-flex; align-items: center; flex: none; color: var(--accent); }
74 /* Every card has the same height: two lines of description, one of topics78 /* Every card has the same height: two lines of description, one of topics
75 (reserved when there are none), five of commits. */79 (reserved when there are none), five of commits. */
76 & .repo-desc {80 & .repo-desc {
@@ -123,7 +127,7 @@
123 & .repo-stats dd a { color: inherit; text-decoration: none; }127 & .repo-stats dd a { color: inherit; text-decoration: none; }
124 & .repo-stats dd a:hover { text-decoration: underline; }128 & .repo-stats dd a:hover { text-decoration: underline; }
125 & .repo-ci {129 & .repo-ci {
126- flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;130+ flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px;
127 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;131 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
128 }132 }
129 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }133 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
@@ -170,7 +174,7 @@
170 }174 }
171 & .repo-main, & .repo-head { display: contents; }175 & .repo-main, & .repo-head { display: contents; }
172 & .repo-name { grid-area: name; align-self: center; font-size: 16px; }176 & .repo-name { grid-area: name; align-self: center; font-size: 16px; }
173- & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; }177+ & .repo-ci { grid-area: ci; align-self: center; }
174 & .repo-desc { grid-area: desc; margin-top: var(--s2); }178 & .repo-desc { grid-area: desc; margin-top: var(--s2); }
175 & .repo-topics { grid-area: topics; margin-top: var(--s2); }179 & .repo-topics { grid-area: topics; margin-top: var(--s2); }
176 & .repo-topics:empty { display: none; }180 & .repo-topics:empty { display: none; }
@@ -212,7 +216,8 @@
212 /* min-width 0: a long name is cut, it does not widen the column past its cap */216 /* min-width 0: a long name is cut, it does not widen the column past its cap */
213 & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }217 & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
214 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }218 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
215- & .repo-name .pill { flex: none; }219+ & .repo-name .pill { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
220+ & .repo-name a.pill { max-width: 12ch; }
216 & .repo-desc {221 & .repo-desc {
217 grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;222 grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
218 font-size: var(--fs-s); color: var(--ink-mute);223 font-size: var(--fs-s); color: var(--ink-mute);
@@ -227,13 +232,8 @@
227 position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);232 position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
228 }233 }
229 & .repo-stats .rs-tag dd { max-width: 10ch; }234 & .repo-stats .rs-tag dd { max-width: 10ch; }
230- & .repo-stats .rs-tag dd::before {
231- content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
232- background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
233- }
234 /* the CI state is its mark; the word is in the tooltip and for screen readers */235 /* the CI state is its mark; the word is in the tooltip and for screen readers */
235- & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; }236+ & .repo-ci { grid-area: ci; justify-self: center; }
236- & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
237 /* the pulse: the same curve, small, with a bigger dot to stay seen */237 /* the pulse: the same curve, small, with a bigger dot to stay seen */
238 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }238 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
239 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }239 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
M internal/adapters/web/static/css/40-summary.css +5 -0
@@ -13,6 +13,11 @@
1313 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
1414
1515 .readme .prose { padding: var(--s5) var(--s6) var(--s6); }
16+ .doc-tabs {
17+ display: flex; flex-wrap: wrap; gap: var(--s4);
18+ & a { color: var(--ink-dim); }
19+ & a[aria-current] { color: var(--teal); text-decoration: underline; text-underline-offset: 5px; }
20+ }
1621
1722 /* rendered Markdown */
1823 .prose {
@@ -13,6 +13,11 @@
13 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }13 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
14 14
15 .readme .prose { padding: var(--s5) var(--s6) var(--s6); }15 .readme .prose { padding: var(--s5) var(--s6) var(--s6); }
16+ .doc-tabs {
17+ display: flex; flex-wrap: wrap; gap: var(--s4);
18+ & a { color: var(--ink-dim); }
19+ & a[aria-current] { color: var(--teal); text-decoration: underline; text-underline-offset: 5px; }
20+ }
16 21
17 /* rendered Markdown */22 /* rendered Markdown */
18 .prose {23 .prose {
A internal/adapters/web/static/icons/refresh-cw.svg +3 -0
@@ -0,0 +1,3 @@
1+<!-- SPDX-License-Identifier: ISC -->
2+<!-- Lucide 1.8: refresh-cw; https://lucide.dev/license -->
3+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
@@ -0,0 +1,3 @@
1+<!-- SPDX-License-Identifier: ISC -->
2+<!-- Lucide 1.8: refresh-cw; https://lucide.dev/license -->
3+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
M internal/adapters/web/static/js/ermite.js +10 -1
@@ -37,7 +37,8 @@
3737 flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
3838 };
3939
40- const visibleRows = () => $$('[data-rows] > :not([hidden])');
40+ // Rail tables are context, not the main row cursor.
41+ const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail'));
4142 // setCursor puts data-cursor on el and takes it from the others.
4243 const setCursor = (el, others) => {
4344 for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
@@ -1007,6 +1008,14 @@ window.ermiteLive = (() => {
10071008 const cursor = document.querySelector('[data-rows] > [data-cursor]');
10081009 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
10091010 for (const el of [...t.content.children]) {
1011+ // Each fresh fragment owns its timer; an older swap cannot expire it.
1012+ // No animation, including for visitors who prefer reduced motion.
1013+ if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) {
1014+ setTimeout(() => {
1015+ el.querySelector('.repo-activity')?.remove();
1016+ el.removeAttribute('data-activity');
1017+ }, 60000);
1018+ }
10101019 const old = el.id && document.getElementById(el.id);
10111020 const had = old ? where(old) : null; // the focus is inside what is replaced
10121021 if (el.hasAttribute('data-gone')) {
@@ -37,7 +37,8 @@
37 flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);37 flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
38 };38 };
39 39
40- const visibleRows = () => $$('[data-rows] > :not([hidden])');40+ // Rail tables are context, not the main row cursor.
41+ const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail'));
41 // setCursor puts data-cursor on el and takes it from the others.42 // setCursor puts data-cursor on el and takes it from the others.
42 const setCursor = (el, others) => {43 const setCursor = (el, others) => {
43 for (const o of others) if (o !== el) o.removeAttribute('data-cursor');44 for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
@@ -1007,6 +1008,14 @@ window.ermiteLive = (() => {
1007 const cursor = document.querySelector('[data-rows] > [data-cursor]');1008 const cursor = document.querySelector('[data-rows] > [data-cursor]');
1008 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');1009 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
1009 for (const el of [...t.content.children]) {1010 for (const el of [...t.content.children]) {
1011+ // Each fresh fragment owns its timer; an older swap cannot expire it.
1012+ // No animation, including for visitors who prefer reduced motion.
1013+ if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) {
1014+ setTimeout(() => {
1015+ el.querySelector('.repo-activity')?.remove();
1016+ el.removeAttribute('data-activity');
1017+ }, 60000);
1018+ }
1010 const old = el.id && document.getElementById(el.id);1019 const old = el.id && document.getElementById(el.id);
1011 const had = old ? where(old) : null; // the focus is inside what is replaced1020 const had = old ? where(old) : null; // the focus is inside what is replaced
1012 if (el.hasAttribute('data-gone')) {1021 if (el.hasAttribute('data-gone')) {
M internal/adapters/web/summary.go +53 -29
@@ -10,6 +10,7 @@ import (
1010 "html/template"
1111 "io"
1212 "net/http"
13+ "net/url"
1314 "strings"
1415 "time"
1516
@@ -59,22 +60,23 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
5960 return nil, 0, err
6061 }
6162 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
62- for i, c := range commits.Commits {
63+ for _, c := range commits.Commits {
6364 id := c.ID.String()
6465 row := commitRow(d.name(), c, now)
65- row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0
66+ row.Branches, row.Tags = branchesAt[id], tagsAt[id]
6667 p.Recent = append(p.Recent, row)
6768 }
6869
70+ p.License = s.repositoryLicense(ctx, d.repo.Name, d.head.CommitID.String())
6971 p.Facts = s.facts(d, now)
7072 p.Latest = s.latest(ctx, d)
7173 p.Release = s.summaryRelease(ctx, d.repo.Name, now)
7274 p.Actions = s.recentActions(ctx, d.repo.Name, now)
7375
74- if rd, err := s.readme(ctx, d, ref); err != nil {
76+ if rd, tabs, err := s.summaryDoc(ctx, d, ref, r.URL.Query().Get("doc")); err != nil {
7577 return nil, 0, err
7678 } else if rd != nil {
77- p.Readme, p.Outline = rd.readme, rd.outline
79+ p.Readme, p.Outline, p.Docs = rd.readme, rd.outline, tabs
7880 }
7981
8082 l := s.repoLayout(ctx, d, "summary")
@@ -147,40 +149,62 @@ type renderedReadme struct {
147149 outline []Heading
148150 }
149151
150-// readme finds the README at the root of the tree of the head commit, reads
151-// it and renders it. It is nil when the repository has none. Rendering
152-// problems degrade to plain text; only use case failures are errors.
153-func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
152+// summaryDoc renders only a listed root document; unknown selections use the first.
153+func (s *Server) summaryDoc(ctx context.Context, d *repoData, ref, selected string) (*renderedReadme, []DocTab, error) {
154154 rev := d.head.CommitID.String()
155155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
156156 if err != nil {
157- return nil, err
157+ return nil, nil, err
158+ }
159+ entries := summaryDocs(tree.Entries)
160+ if len(entries) == 0 {
161+ return nil, nil, nil
158162 }
159- return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)
163+ index := 0
164+ for i, e := range entries {
165+ if e.Name == selected {
166+ index = i
167+ break
168+ }
169+ if strings.EqualFold(e.Name, selected) {
170+ index = i
171+ }
172+ }
173+ tabs := make([]DocTab, 0, len(entries))
174+ for i, e := range entries {
175+ tabs = append(tabs, DocTab{Name: e.Name, Href: "?doc=" + url.QueryEscape(e.Name), Current: i == index})
176+ }
177+ rd, err := s.renderDoc(ctx, d.repo.Name, rev, ref, "", entries[index])
178+ return rd, tabs, err
160179 }
161180
162-// readmeIn picks the README among the entries of the directory dir ("" for
163-// the root) of the commit rev, reads it and renders it. Links in it are
164-// relative to dir, on the ref ref. It is nil when the directory has none.
165-//
166-//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
167-func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
168- var best *domain.TreeEntry
169- rank := len(readmeNames)
170- for i := range entries {
171- e := &entries[i]
172- if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
173- continue
174- }
175- for i, n := range readmeNames {
176- if i < rank && strings.EqualFold(e.Name, n) {
177- best, rank = e, i
181+// summaryDocs lists the preferred README, then the common project documents.
182+func summaryDocs(entries []domain.TreeEntry) []domain.TreeEntry {
183+ var docs []domain.TreeEntry
184+ groups := [][]string{readmeNames}
185+ for _, base := range []string{"license", "licence", "copying", "contributing", "code_of_conduct", "security"} {
186+ groups = append(groups, []string{base, base + ".md", base + ".markdown", base + ".txt"})
187+ }
188+ for g, names := range groups {
189+ for _, name := range names {
190+ for _, e := range entries {
191+ if (e.Kind == domain.EntryFile || e.Kind == domain.EntryExecutable) && strings.EqualFold(e.Name, name) {
192+ docs = append(docs, e)
193+ }
194+ }
195+ if g == 0 && len(docs) > 0 {
196+ break
178197 }
179198 }
180199 }
181- if best == nil {
182- return nil, nil
183- }
200+ return docs
201+}
202+
203+// renderDoc shares the README limits and rendering with the summary documents.
204+//
205+//hexcheck:ignore suspicious-business-logic-in-adapter reads and renders a root document to show; display only
206+func (s *Server) renderDoc(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entry domain.TreeEntry) (*renderedReadme, error) {
207+ best := &entry
184208 rd := &Readme{Name: best.Name}
185209 if best.Size > maxReadmeBytes {
186210 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
@@ -10,6 +10,7 @@ import (
10 "html/template"10 "html/template"
11 "io"11 "io"
12 "net/http"12 "net/http"
13+ "net/url"
13 "strings"14 "strings"
14 "time"15 "time"
15 16
@@ -59,22 +60,23 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
59 return nil, 0, err60 return nil, 0, err
60 }61 }
61 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)62 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
62- for i, c := range commits.Commits {63+ for _, c := range commits.Commits {
63 id := c.ID.String()64 id := c.ID.String()
64 row := commitRow(d.name(), c, now)65 row := commitRow(d.name(), c, now)
65- row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 066+ row.Branches, row.Tags = branchesAt[id], tagsAt[id]
66 p.Recent = append(p.Recent, row)67 p.Recent = append(p.Recent, row)
67 }68 }
68 69
70+ p.License = s.repositoryLicense(ctx, d.repo.Name, d.head.CommitID.String())
69 p.Facts = s.facts(d, now)71 p.Facts = s.facts(d, now)
70 p.Latest = s.latest(ctx, d)72 p.Latest = s.latest(ctx, d)
71 p.Release = s.summaryRelease(ctx, d.repo.Name, now)73 p.Release = s.summaryRelease(ctx, d.repo.Name, now)
72 p.Actions = s.recentActions(ctx, d.repo.Name, now)74 p.Actions = s.recentActions(ctx, d.repo.Name, now)
73 75
74- if rd, err := s.readme(ctx, d, ref); err != nil {76+ if rd, tabs, err := s.summaryDoc(ctx, d, ref, r.URL.Query().Get("doc")); err != nil {
75 return nil, 0, err77 return nil, 0, err
76 } else if rd != nil {78 } else if rd != nil {
77- p.Readme, p.Outline = rd.readme, rd.outline79+ p.Readme, p.Outline, p.Docs = rd.readme, rd.outline, tabs
78 }80 }
79 81
80 l := s.repoLayout(ctx, d, "summary")82 l := s.repoLayout(ctx, d, "summary")
@@ -147,40 +149,62 @@ type renderedReadme struct {
147 outline []Heading149 outline []Heading
148 }150 }
149 151
150-// readme finds the README at the root of the tree of the head commit, reads152+// summaryDoc renders only a listed root document; unknown selections use the first.
151-// it and renders it. It is nil when the repository has none. Rendering153+func (s *Server) summaryDoc(ctx context.Context, d *repoData, ref, selected string) (*renderedReadme, []DocTab, error) {
152-// problems degrade to plain text; only use case failures are errors.
153-func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
154 rev := d.head.CommitID.String()154 rev := d.head.CommitID.String()
155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
156 if err != nil {156 if err != nil {
157- return nil, err157+ return nil, nil, err
158+ }
159+ entries := summaryDocs(tree.Entries)
160+ if len(entries) == 0 {
161+ return nil, nil, nil
158 }162 }
159- return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)163+ index := 0
164+ for i, e := range entries {
165+ if e.Name == selected {
166+ index = i
167+ break
168+ }
169+ if strings.EqualFold(e.Name, selected) {
170+ index = i
171+ }
172+ }
173+ tabs := make([]DocTab, 0, len(entries))
174+ for i, e := range entries {
175+ tabs = append(tabs, DocTab{Name: e.Name, Href: "?doc=" + url.QueryEscape(e.Name), Current: i == index})
176+ }
177+ rd, err := s.renderDoc(ctx, d.repo.Name, rev, ref, "", entries[index])
178+ return rd, tabs, err
160 }179 }
161 180
162-// readmeIn picks the README among the entries of the directory dir ("" for181+// summaryDocs lists the preferred README, then the common project documents.
163-// the root) of the commit rev, reads it and renders it. Links in it are182+func summaryDocs(entries []domain.TreeEntry) []domain.TreeEntry {
164-// relative to dir, on the ref ref. It is nil when the directory has none.183+ var docs []domain.TreeEntry
165-//184+ groups := [][]string{readmeNames}
166-//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only185+ for _, base := range []string{"license", "licence", "copying", "contributing", "code_of_conduct", "security"} {
167-func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {186+ groups = append(groups, []string{base, base + ".md", base + ".markdown", base + ".txt"})
168- var best *domain.TreeEntry187+ }
169- rank := len(readmeNames)188+ for g, names := range groups {
170- for i := range entries {189+ for _, name := range names {
171- e := &entries[i]190+ for _, e := range entries {
172- if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {191+ if (e.Kind == domain.EntryFile || e.Kind == domain.EntryExecutable) && strings.EqualFold(e.Name, name) {
173- continue192+ docs = append(docs, e)
174- }193+ }
175- for i, n := range readmeNames {194+ }
176- if i < rank && strings.EqualFold(e.Name, n) {195+ if g == 0 && len(docs) > 0 {
177- best, rank = e, i196+ break
178 }197 }
179 }198 }
180 }199 }
181- if best == nil {200+ return docs
182- return nil, nil201+}
183- }202+
203+// renderDoc shares the README limits and rendering with the summary documents.
204+//
205+//hexcheck:ignore suspicious-business-logic-in-adapter reads and renders a root document to show; display only
206+func (s *Server) renderDoc(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entry domain.TreeEntry) (*renderedReadme, error) {
207+ best := &entry
184 rd := &Readme{Name: best.Name}208 rd := &Readme{Name: best.Name}
185 if best.Size > maxReadmeBytes {209 if best.Size > maxReadmeBytes {
186 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."210 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
A internal/adapters/web/summary_docs_test.go +157 -0
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func TestSummaryDocs(t *testing.T) {
23+ t.Parallel()
24+ entries := []domain.TreeEntry{
25+ {Name: "readme.txt", Kind: domain.EntryFile},
26+ {Name: "SECURITY.md", Kind: domain.EntryFile},
27+ {Name: "LICENSE", Kind: domain.EntryExecutable},
28+ {Name: "README.md", Kind: domain.EntryFile},
29+ {Name: "CONTRIBUTING.md", Kind: domain.EntryDirectory},
30+ {Name: "COPYING.txt", Kind: domain.EntryFile},
31+ {Name: "code_of_conduct.MD", Kind: domain.EntryFile},
32+ }
33+ docs := summaryDocs(entries)
34+ var names []string
35+ for _, e := range docs {
36+ names = append(names, e.Name)
37+ }
38+ assert.Equal(t, []string{"README.md", "LICENSE", "COPYING.txt", "code_of_conduct.MD", "SECURITY.md"}, names)
39+ assert.Empty(t, summaryDocs(nil))
40+}
41+
42+func TestSummaryDocSelection(t *testing.T) {
43+ t.Parallel()
44+ for _, tc := range []struct {
45+ selected, want, body string
46+ markdown, noReadme bool
47+ }{
48+ {"", "README.md", "# Read me", true, false},
49+ {"security.MD", "SECURITY.md", "# Reporting", true, false},
50+ {"LICENSE", "LICENSE", "<license>", false, false},
51+ {"../secret", "README.md", "# Read me", true, false},
52+ {"", "LICENSE", "<license>", false, true},
53+ } {
54+ t.Run(tc.selected+tc.want, func(t *testing.T) {
55+ t.Parallel()
56+ repo := repoName(t, "r")
57+ browse := mocks.NewBrowseService(t)
58+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "SECURITY.md", Path: "SECURITY.md", Kind: domain.EntryFile}}
59+ if !tc.noReadme {
60+ entries = append(entries, domain.TreeEntry{Name: "README.md", Path: "README.md", Kind: domain.EntryFile})
61+ }
62+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
63+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
64+ assert.Equal(t, tc.want, path.String())
65+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader(tc.body)), nil
66+ })
67+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
68+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
69+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", tc.selected)
70+ require.NoError(t, err)
71+ require.NotNil(t, rd)
72+ assert.Equal(t, tc.want, rd.readme.Name)
73+ if tc.markdown {
74+ assert.Contains(t, string(rd.readme.HTML), "<h1")
75+ assert.Len(t, rd.outline, 1)
76+ } else {
77+ assert.Equal(t, "<pre>&lt;license&gt;</pre>", string(rd.readme.HTML))
78+ assert.Empty(t, rd.outline)
79+ }
80+ current := 0
81+ for _, tab := range tabs {
82+ if tab.Current {
83+ current++
84+ assert.Equal(t, tc.want, tab.Name)
85+ }
86+ }
87+ assert.Equal(t, 1, current)
88+ out := renderToString(t, ReadmeCard(*rd.readme, tabs...))
89+ assert.Contains(t, out, `aria-label="Repository documents"`)
90+ assert.Contains(t, out, `aria-current="page"`)
91+ assert.Contains(t, out, `href="?doc=LICENSE"`)
92+ })
93+ }
94+}
95+
96+func TestSummaryDocLimits(t *testing.T) {
97+ t.Parallel()
98+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
99+ rd, err := s.renderDoc(t.Context(), repoName(t, "r"), mainTip, "main", "", domain.TreeEntry{Name: "LICENSE", Size: maxReadmeBytes + 1})
100+ require.NoError(t, err)
101+ assert.Contains(t, rd.readme.Note, "too large")
102+ assert.Empty(t, rd.outline)
103+ out := renderToString(t, ReadmeCard(*rd.readme))
104+ assert.NotContains(t, out, "doc-tabs")
105+}
106+
107+func TestSummaryArrivalHasNoRailCursor(t *testing.T) {
108+ t.Parallel()
109+ repo := repoName(t, "r")
110+ browse := mocks.NewBrowseService(t)
111+ tip := objectID(t, mainTip)
112+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: tip}}}, nil)
113+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: []domain.Commit{{ID: tip, Summary: "recent commit"}}}, nil)
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil)
115+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
116+ req := httptest.NewRequest(http.MethodGet, "/r", nil)
117+ req.SetPathValue("repo", "r")
118+ page, status, err := s.summary(t.Context(), req)
119+ require.NoError(t, err)
120+ assert.Equal(t, http.StatusOK, status)
121+ out := renderToString(t, page)
122+ assert.Contains(t, out, "recent commit")
123+ _, rail, found := strings.Cut(out, `<aside class="rail"`)
124+ require.True(t, found)
125+ rail, _, _ = strings.Cut(rail, "</aside>")
126+ assert.NotContains(t, rail, "data-cursor")
127+ assert.NotContains(t, rail, "autofocus")
128+}
129+
130+func TestSummaryDocExactFilename(t *testing.T) {
131+ t.Parallel()
132+ for _, selected := range []string{"LICENSE", "license"} {
133+ t.Run(selected, func(t *testing.T) {
134+ t.Parallel()
135+ repo := repoName(t, "r")
136+ browse := mocks.NewBrowseService(t)
137+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "license", Path: "license", Kind: domain.EntryFile}}
138+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
139+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
140+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader("content of " + path.String())), nil
141+ })
142+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
143+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
144+ req := httptest.NewRequest(http.MethodGet, "/r?doc="+selected, nil)
145+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", req.URL.Query().Get("doc"))
146+ require.NoError(t, err)
147+ require.NotNil(t, rd)
148+ assert.Equal(t, selected, rd.readme.Name)
149+ assert.Equal(t, "<pre>content of "+selected+"</pre>", string(rd.readme.HTML))
150+ require.Len(t, tabs, 2)
151+ for _, tab := range tabs {
152+ assert.Equal(t, tab.Name == selected, tab.Current, tab.Name)
153+ assert.Equal(t, "?doc="+tab.Name, tab.Href)
154+ }
155+ })
156+ }
157+}
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func TestSummaryDocs(t *testing.T) {
23+ t.Parallel()
24+ entries := []domain.TreeEntry{
25+ {Name: "readme.txt", Kind: domain.EntryFile},
26+ {Name: "SECURITY.md", Kind: domain.EntryFile},
27+ {Name: "LICENSE", Kind: domain.EntryExecutable},
28+ {Name: "README.md", Kind: domain.EntryFile},
29+ {Name: "CONTRIBUTING.md", Kind: domain.EntryDirectory},
30+ {Name: "COPYING.txt", Kind: domain.EntryFile},
31+ {Name: "code_of_conduct.MD", Kind: domain.EntryFile},
32+ }
33+ docs := summaryDocs(entries)
34+ var names []string
35+ for _, e := range docs {
36+ names = append(names, e.Name)
37+ }
38+ assert.Equal(t, []string{"README.md", "LICENSE", "COPYING.txt", "code_of_conduct.MD", "SECURITY.md"}, names)
39+ assert.Empty(t, summaryDocs(nil))
40+}
41+
42+func TestSummaryDocSelection(t *testing.T) {
43+ t.Parallel()
44+ for _, tc := range []struct {
45+ selected, want, body string
46+ markdown, noReadme bool
47+ }{
48+ {"", "README.md", "# Read me", true, false},
49+ {"security.MD", "SECURITY.md", "# Reporting", true, false},
50+ {"LICENSE", "LICENSE", "<license>", false, false},
51+ {"../secret", "README.md", "# Read me", true, false},
52+ {"", "LICENSE", "<license>", false, true},
53+ } {
54+ t.Run(tc.selected+tc.want, func(t *testing.T) {
55+ t.Parallel()
56+ repo := repoName(t, "r")
57+ browse := mocks.NewBrowseService(t)
58+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "SECURITY.md", Path: "SECURITY.md", Kind: domain.EntryFile}}
59+ if !tc.noReadme {
60+ entries = append(entries, domain.TreeEntry{Name: "README.md", Path: "README.md", Kind: domain.EntryFile})
61+ }
62+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
63+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
64+ assert.Equal(t, tc.want, path.String())
65+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader(tc.body)), nil
66+ })
67+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
68+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
69+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", tc.selected)
70+ require.NoError(t, err)
71+ require.NotNil(t, rd)
72+ assert.Equal(t, tc.want, rd.readme.Name)
73+ if tc.markdown {
74+ assert.Contains(t, string(rd.readme.HTML), "<h1")
75+ assert.Len(t, rd.outline, 1)
76+ } else {
77+ assert.Equal(t, "<pre>&lt;license&gt;</pre>", string(rd.readme.HTML))
78+ assert.Empty(t, rd.outline)
79+ }
80+ current := 0
81+ for _, tab := range tabs {
82+ if tab.Current {
83+ current++
84+ assert.Equal(t, tc.want, tab.Name)
85+ }
86+ }
87+ assert.Equal(t, 1, current)
88+ out := renderToString(t, ReadmeCard(*rd.readme, tabs...))
89+ assert.Contains(t, out, `aria-label="Repository documents"`)
90+ assert.Contains(t, out, `aria-current="page"`)
91+ assert.Contains(t, out, `href="?doc=LICENSE"`)
92+ })
93+ }
94+}
95+
96+func TestSummaryDocLimits(t *testing.T) {
97+ t.Parallel()
98+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
99+ rd, err := s.renderDoc(t.Context(), repoName(t, "r"), mainTip, "main", "", domain.TreeEntry{Name: "LICENSE", Size: maxReadmeBytes + 1})
100+ require.NoError(t, err)
101+ assert.Contains(t, rd.readme.Note, "too large")
102+ assert.Empty(t, rd.outline)
103+ out := renderToString(t, ReadmeCard(*rd.readme))
104+ assert.NotContains(t, out, "doc-tabs")
105+}
106+
107+func TestSummaryArrivalHasNoRailCursor(t *testing.T) {
108+ t.Parallel()
109+ repo := repoName(t, "r")
110+ browse := mocks.NewBrowseService(t)
111+ tip := objectID(t, mainTip)
112+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: tip}}}, nil)
113+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: []domain.Commit{{ID: tip, Summary: "recent commit"}}}, nil)
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil)
115+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
116+ req := httptest.NewRequest(http.MethodGet, "/r", nil)
117+ req.SetPathValue("repo", "r")
118+ page, status, err := s.summary(t.Context(), req)
119+ require.NoError(t, err)
120+ assert.Equal(t, http.StatusOK, status)
121+ out := renderToString(t, page)
122+ assert.Contains(t, out, "recent commit")
123+ _, rail, found := strings.Cut(out, `<aside class="rail"`)
124+ require.True(t, found)
125+ rail, _, _ = strings.Cut(rail, "</aside>")
126+ assert.NotContains(t, rail, "data-cursor")
127+ assert.NotContains(t, rail, "autofocus")
128+}
129+
130+func TestSummaryDocExactFilename(t *testing.T) {
131+ t.Parallel()
132+ for _, selected := range []string{"LICENSE", "license"} {
133+ t.Run(selected, func(t *testing.T) {
134+ t.Parallel()
135+ repo := repoName(t, "r")
136+ browse := mocks.NewBrowseService(t)
137+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "license", Path: "license", Kind: domain.EntryFile}}
138+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
139+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
140+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader("content of " + path.String())), nil
141+ })
142+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
143+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
144+ req := httptest.NewRequest(http.MethodGet, "/r?doc="+selected, nil)
145+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", req.URL.Query().Get("doc"))
146+ require.NoError(t, err)
147+ require.NotNil(t, rd)
148+ assert.Equal(t, selected, rd.readme.Name)
149+ assert.Equal(t, "<pre>content of "+selected+"</pre>", string(rd.readme.HTML))
150+ require.Len(t, tabs, 2)
151+ for _, tab := range tabs {
152+ assert.Equal(t, tab.Name == selected, tab.Current, tab.Name)
153+ assert.Equal(t, "?doc="+tab.Name, tab.Href)
154+ }
155+ })
156+ }
157+}
M internal/adapters/web/view.go +14 -4
@@ -84,10 +84,12 @@ type Link struct{ Label, Href string }
8484
8585 // HomeRow is one repository in the home list.
8686 type HomeRow struct {
87- ID string
88- Name string
89- Href string
90- Desc string
87+ Release *LatestRelease
88+ License *LicenseBadge
89+ ID string
90+ Name string
91+ Href string
92+ Desc string
9193 // Topics classify the repository, as on GitHub.
9294 Topics []string
9395 // Active is when the repository last changed (its last push or merge),
@@ -201,8 +203,16 @@ type CommitRow struct {
201203 Sig *SignatureBadge
202204 }
203205
206+// DocTab links to a root document on the summary.
207+type DocTab struct {
208+ Name, Href string
209+ Current bool
210+}
211+
204212 // SummaryPage is the repository summary.
205213 type SummaryPage struct {
214+ Docs []DocTab
215+ License *LicenseBadge
206216 Mirror *MirrorBadge
207217 Facts []Fact
208218 Clone Clone
@@ -84,10 +84,12 @@ type Link struct{ Label, Href string }
84 84
85 // HomeRow is one repository in the home list.85 // HomeRow is one repository in the home list.
86 type HomeRow struct {86 type HomeRow struct {
87- ID string87+ Release *LatestRelease
88- Name string88+ License *LicenseBadge
89- Href string89+ ID string
90- Desc string90+ Name string
91+ Href string
92+ Desc string
91 // Topics classify the repository, as on GitHub.93 // Topics classify the repository, as on GitHub.
92 Topics []string94 Topics []string
93 // Active is when the repository last changed (its last push or merge),95 // Active is when the repository last changed (its last push or merge),
@@ -201,8 +203,16 @@ type CommitRow struct {
201 Sig *SignatureBadge203 Sig *SignatureBadge
202 }204 }
203 205
206+// DocTab links to a root document on the summary.
207+type DocTab struct {
208+ Name, Href string
209+ Current bool
210+}
211+
204 // SummaryPage is the repository summary.212 // SummaryPage is the repository summary.
205 type SummaryPage struct {213 type SummaryPage struct {
214+ Docs []DocTab
215+ License *LicenseBadge
206 Mirror *MirrorBadge216 Mirror *MirrorBadge
207 Facts []Fact217 Facts []Fact
208 Clone Clone218 Clone Clone
M internal/adapters/web/web.go +3 -0
@@ -139,6 +139,8 @@ type Server struct {
139139 md *markdown
140140 // pathCache holds the path lists the file finder fetches.
141141 pathCache *pathCache
142+ // licenses holds small display results, including missing licenses.
143+ licenses *pathCache
142144 // hl holds the colours of highlighted diffs.
143145 hl *hlCache
144146 mux *http.ServeMux
@@ -174,6 +176,7 @@ func New(cfg Config) (*Server, error) {
174176 s := &Server{
175177 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
176178 pathCache: newPathCache(pathCacheBytes, pathBuilds),
179+ licenses: newPathCache(1<<20, homeWorkers),
177180 hl: newHLCache(hlCacheBytes),
178181 origin: originOf(u),
179182 }
@@ -139,6 +139,8 @@ type Server struct {
139 md *markdown139 md *markdown
140 // pathCache holds the path lists the file finder fetches.140 // pathCache holds the path lists the file finder fetches.
141 pathCache *pathCache141 pathCache *pathCache
142+ // licenses holds small display results, including missing licenses.
143+ licenses *pathCache
142 // hl holds the colours of highlighted diffs.144 // hl holds the colours of highlighted diffs.
143 hl *hlCache145 hl *hlCache
144 mux *http.ServeMux146 mux *http.ServeMux
@@ -174,6 +176,7 @@ func New(cfg Config) (*Server, error) {
174 s := &Server{176 s := &Server{
175 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),177 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
176 pathCache: newPathCache(pathCacheBytes, pathBuilds),178 pathCache: newPathCache(pathCacheBytes, pathBuilds),
179+ licenses: newPathCache(1<<20, homeWorkers),
177 hl: newHLCache(hlCacheBytes),180 hl: newHLCache(hlCacheBytes),
178 origin: originOf(u),181 origin: originOf(u),
179 }182 }
M internal/app/testdata/web/summary.html +2 -2
@@ -99,7 +99,7 @@
9999 <p class="lede">A demo repository</p>
100100 </section>
101101 <main id="main" tabindex="-1">
102-<section class="card readme" aria-labelledby="readme-h">
102+<section class="card readme" aria-label="README.md">
103103 <h2 class="card-h" id="readme-h">
104104 <span class="ico i-file" aria-hidden="true">
105105 </span> README.md</h2>
@@ -164,7 +164,7 @@
164164 </h2>
165165 <table class="list commits" aria-label="Commits">
166166 <tbody data-rows>
167-<tr data-cursor>
167+<tr>
168168 <td class="c-sha mute">SHA7</td>
169169 <td class="c-subject">
170170 <a href="/demo/commit/SHA40">add main.go</a>
@@ -99,7 +99,7 @@
99 <p class="lede">A demo repository</p>99 <p class="lede">A demo repository</p>
100 </section>100 </section>
101 <main id="main" tabindex="-1">101 <main id="main" tabindex="-1">
102-<section class="card readme" aria-labelledby="readme-h">102+<section class="card readme" aria-label="README.md">
103 <h2 class="card-h" id="readme-h">103 <h2 class="card-h" id="readme-h">
104 <span class="ico i-file" aria-hidden="true">104 <span class="ico i-file" aria-hidden="true">
105 </span> README.md</h2>105 </span> README.md</h2>
@@ -164,7 +164,7 @@
164 </h2>164 </h2>
165 <table class="list commits" aria-label="Commits">165 <table class="list commits" aria-label="Commits">
166 <tbody data-rows>166 <tbody data-rows>
167-<tr data-cursor>167+<tr>
168 <td class="c-sha mute">SHA7</td>168 <td class="c-sha mute">SHA7</td>
169 <td class="c-subject">169 <td class="c-subject">
170 <a href="/demo/commit/SHA40">add main.go</a>170 <a href="/demo/commit/SHA40">add main.go</a>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close