detached 67508cc

#4 feat(issues): native issues, owner inbox and moderated public form

state
merged · 67508cc · fast-forward
branch
feat/issue-form → main
opened
bnema ·
updated

Implements ADR 0031: native issues, the owner Inbox, and the moderated public issue form. It contains the three stacked branches feat/issues → feat/inbox → feat/issue-form, and each phase was reviewed before the next.

What it does

  • Issues:
    • Issues share the per-repo number with merge requests, so #n is one or the other and links redirect both ways.
    • New scope issue:write. Agents can create, comment, close and reopen issues without moderation.
    • Per-repo setting issues: off|owner|public, default off. A repo with issues off answers the same 404 as an unknown repo.
    • CLI ermite issue create|list|show|comment|close|reopen and repo edit --issues.
    • Web: an Issues tab, live updates, and owner forms.
  • Inbox (/-/inbox, owner only):
    • Counters at the top, then the sections Awaiting moderation, Open merge requests and Open issues.
    • Repository filter, and an owner live stream across all repositories.
    • Button at the top left of the home page, shown only when signed in.
  • Public form (mode public):
    • JavaScript required. A same-origin worker solves a proof of work signed by the server (18 to 21 bits, adaptive).
    • Honeypot field and a minimum fill time of 3 s.
    • Single-use challenge and form token.
    • Limits of 3/hour and 10/day per network, and a cap of 200 pending issues, both reserved atomically.
    • Bans last 30 days and are keyed on an HMAC of the address. No IP is written to disk.
    • Until the proof of work is valid, every refusal is the same generic 400, so nothing reveals a ban, a limit or a full queue.
    • Moderation (approve, reject, reject and ban) happens in the Inbox and over the API.
    • Approved anonymous issues render untrusted Markdown. Pending issues render as plain text.
  • Storage: schema 3 → 4. The upgrade adds issue:write to stored tokens that already hold every scope, so they keep their owner rights.

Verification

  • Phase reviews (go-reviewer) and a JS review (ts-reviewer). All findings are fixed, including:
    • comment loss after retries;
    • issue events delivered after issues were turned off;
    • the bypass of the per-network limit and of the cap under concurrency;
    • the ban oracle;
    • the emoji/NFC counters;
    • restoring the page from bfcache.
  • Final wrap-up review: every point is fixed except the deferred one listed below.
  • End-to-end test in headless Chromium at production difficulty, measured as:
    • a proof-of-work solve of 0.25 to 4.4 s;
    • moderation, then approval as #1;
    • 403 without Origin, 400 on token reuse, 429 with Retry-After;
    • the no-JS message shown instead of the form.
  • go vet, go test -race ./... (full suite), hexcheck, templ drift, buf generate, the mocks and go mod tidy all pass. All 155 JS tests pass. All commits are signed.

Before leaving draft

  • Merge MR !1 (feat/mr-notes) first. Then rebase this branch on main and extend the v3 → v4 upgrade so that it grants mr:review and issue:write to tokens holding the 9 original scopes. Also restore mr:review in client.AllScopes() and drop the temporary Unimplemented MR note handlers in rpc. Do not deploy main between the two merges: existing all-scope tokens would lose their owner rights.
  • Merge the ermite-api MR (feat/issue-form) and tag it. Replace the local replace bnema.dev/ermite-api with go get bnema.dev/ermite-api@<tag>, then run go mod tidy.
  • Deferred (GO-004, scaling): the home badge and every Inbox event compute the full Overview. This is fine on a solo instance; a lighter count-only path can come later.
  • Behind a reverse proxy, set ERMITE_TRUSTED_PROXIES. Without it, every visitor shares the proxy's per-network limit.
CommitSubjectAuthorDateSignatureStatus
67508cctest(usecase): run the issue moderation subtests in parallelbriceGPG Verified
f6f76c8build(deps): ermite-api v0.9.0briceGPG Verified
7e0e489feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgradebriceGPG Verified
fa37646merge: main with merge request notes into feat/issue-formbriceGPG Verified
346e37fdocs: correct issue access, limiter and comment docsbriceGPG Verified
e53302brefactor(kvstore): keep the hexcheck waiver on issue approvalbriceGPG Verified
ddab9a2fix(rpc): answer not found to non-owners before parsing moderation requestsbriceGPG Verified
f8b1e92refactor: drop the unused pending issue state, ListOpen and InboxMergeRequestbriceGPG Verified
1486f6drefactor(domain): build approved issues in the domainbriceGPG Verified
0284609perf(kvstore): page issues without decoding the whole historybriceGPG Verified
17b5f65fix(kvstore): bound the numbering locksbriceGPG Verified
b0db7adfix(usecase): refresh the inbox after a rejectionbriceGPG Verified
03a1ab8Merge branch 'feat/inbox' into feat/issue-formbriceGPG Verified
0092b37Merge branch 'feat/issues' into feat/inboxbriceGPG Verified
30f7beafix(rpc): build against the api with merge request notes and reject scope mr:reviewbriceGPG Verified
b5344bffix(usecase): verify the proof of work before any admission check and reserve quota and queue atomicallybriceGPG Verified
bc78630fix(web): count issue form limits like the server and refresh the challenge after back navigationbriceGPG Verified
e687d9cdocs: public issue form and moderationbriceGPG Verified
cce8cfafeat(web): public issue form and moderation in the inboxbriceGPG Verified
093dc62feat(web): proof of work solverbriceGPG Verified
3f9aa10feat(web): untrusted markdown for external issuesbriceGPG Verified
99db356feat(usecase): allow public issuesbriceGPG Verified
a26798bfeat(usecase): anonymous issue submission with adaptive proof of workbriceGPG Verified
ee8d39cfeat(rpc): serve pending issue moderation and wire it in the forgebriceGPG Verified
51f0135feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted eventbriceGPG Verified
2576c64fix(clientlimit): keep the submission limits free of the domain importbriceGPG Verified
8d2ab49feat(clientlimit): add the sliding window submission limiterbriceGPG Verified
6a3e65dfeat(kvstore): store pending issues, bans and the issue form keybriceGPG Verified
dca1fe7feat(ports): add the pending issue, key, ban, submission limiter and moderation portsbriceGPG Verified
784ea9ffeat(domain): add pending issues, the issue submitted event and the form limitsbriceGPG Verified
babd8c2build(deps): use the local ermite-api issue form branchbriceGPG Verified
d66b108test(events): owner topic access matrix, token end, bearer refusal, stale indexbriceGPG Verified
063865bfix(events): deliver repo.updated and repo.deleted to owner watchers and refresh the inboxbriceGPG Verified
bc6524cfeat(web): owner inbox page and home buttonbriceGPG Verified
ab38ed2feat(events): owner topic across repositoriesbriceGPG Verified
974935dfeat(usecase): owner inbox overview across repositoriesbriceGPG Verified
e17a0e5feat(kvstore): list open merge requests and issues across repositoriesbriceGPG Verified
68460c1fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retriesbriceGPG Verified
b2212f2test(app): merge the declaration with the assignment in the issue e2e test (staticcheck S1021)briceGPG Verified
2794dd4fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keysbriceGPG Verified
02692c4fix(events): stop streaming issue events and views once issues are off or the repo is not visiblebriceGPG Verified
4f0da00fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limitsbriceGPG Verified
a4f8ebdfix(usecase): report a conflict when every issue comment save conflicts instead of publishing IssueCommentedbriceGPG Verified
e3b6724merge(web): issue pages into feat/issuesbriceGPG Verified
68db1e6feat(web): annotate the issue tab and transition for hexcheckbriceGPG Verified
fd07554docs: document issuesbriceGPG Verified
582cf61feat(cli): add ermite issue commands and repo edit --issuesbriceGPG Verified
931429bfeat(web): add the issue pages, tab, redirects and live noticesbriceGPG Verified
a5d6827feat(rpc): serve IssueService and the issues setting, wire issuesbriceGPG Verified
4c8ce48feat(usecase): add the issue use cases and the issues settingbriceGPG Verified
c52c40ffeat(storage): store issues on the merge request sequence, schema 4briceGPG Verified
b41adf0feat(domain): add issues, the issues mode, text parsers and the issue:write scopebriceGPG Verified
6815c6cbuild(deps): use the local ermite-api issues branchbriceGPG Verified

Checks 8

176 files · +18416 -1025
7/8
M README.md +1 -0
M docs/cli.md +7 -2
M docs/configuration.md +1 -1
A docs/issues.md +55 -0
M docs/tokens.md +2 -1
A gen/ermite/storage/issue.pb.go +541 -0
M gen/ermite/storage/merge_request.pb.go +109 -38
M go.mod +2 -2
M go.sum +2 -2
M internal/adapters/client/client.go +5 -0
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/client/scopes_test.go +2 -0
M internal/adapters/kvstore/export_test.go +41 -1
A internal/adapters/kvstore/issues.go +437 -0
A internal/adapters/kvstore/issues_pending.go +408 -0
A internal/adapters/kvstore/issues_pending_test.go +271 -0
A internal/adapters/kvstore/issues_test.go +561 -0
M internal/adapters/kvstore/mergerequests.go +123 -26
M internal/adapters/kvstore/mergerequests_test.go +1 -1
M internal/adapters/kvstore/repos.go +3 -1
M internal/adapters/kvstore/store.go +75 -4
M internal/adapters/kvstore/store_test.go +4 -4
M internal/adapters/rpc/api.go +8 -3
M internal/adapters/rpc/convert.go +19 -1
M internal/adapters/rpc/convert_test.go +40 -0
A internal/adapters/rpc/issue.go +262 -0
M internal/adapters/rpc/mergerequest.go +3 -1
M internal/adapters/rpc/mergerequest_test.go +11 -11
M internal/adapters/rpc/repo.go +10 -2
M internal/adapters/web/ASSETS.md +2 -0
M internal/adapters/web/assets.go +3 -1
M internal/adapters/web/events.go +59 -3
M internal/adapters/web/events_test.go +38 -1
M internal/adapters/web/fragments.go +36 -6
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/home.go +3 -0
A internal/adapters/web/inbox.go +294 -0
A internal/adapters/web/inbox.templ +115 -0
A internal/adapters/web/inbox_moderation_test.go +148 -0
A internal/adapters/web/inbox_templ.go +759 -0
A internal/adapters/web/inbox_test.go +310 -0
A internal/adapters/web/issues.go +363 -0
A internal/adapters/web/issues.templ +187 -0
A internal/adapters/web/issues_form.go +276 -0
A internal/adapters/web/issues_form.templ +67 -0
A internal/adapters/web/issues_form_templ.go +421 -0
A internal/adapters/web/issues_form_test.go +345 -0
A internal/adapters/web/issues_post.go +86 -0
A internal/adapters/web/issues_templ.go +885 -0 generated file: collapsed
A internal/adapters/web/issues_test.go +332 -0
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
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 issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
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 issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
M internal/adapters/web/keys.templ +1 -0
@@ -27,6 +27,7 @@ templ Keys() {
2727 k-c from:body -> ermite.tab('c');
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
3031 k-next from:body -> ermite.nextNote();
3132 k-split from:body -> ermite.layout('split');
3233 k-unified from:body -> ermite.layout('unified');
@@ -27,6 +27,7 @@ templ Keys() {
27 k-c from:body -> ermite.tab('c');27 k-c from:body -> ermite.tab('c');
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
30 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
31 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
32 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/markdown.go +73 -7
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6464 // #fragment links chosen, so that several documents on one page (the summaries
6565 // of a run) do not share ids.
6666 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
6868 }
6969
7070 // renderNote uses the repository Markdown policy, then adds local note links.
7171 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
7373 return h, err
7474 }
7575
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
7890 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
91+ pol.source = source
8092 // Collect eligible text before the policy unwraps rejected links: their
8193 // labels must not accidentally become references either.
8294 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163175 source []byte
164176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166192 }
167193
168194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195221 l.Destination = []byte(dest)
196222 l.Title = nil
197223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
224+ l.SetAttributeString("rel", p.rel())
199225 }
200226 }
201227 for _, n := range autos {
202228 a := n.(*ast.AutoLink)
203229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205235 continue
206236 }
207237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242272 return "", dropped
243273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244277 if strings.HasPrefix(dest, "#") {
245278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288321 return href, internal
289322 }
290323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291357 func isWebURL(s string) bool {
292358 u, err := url.Parse(s)
293359 if err != nil || u.Host == "" {
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
64 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
65 // of a run) do not share ids.65 // of a run) do not share ids.
66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
68 }68 }
69 69
70 // renderNote uses the repository Markdown policy, then adds local note links.70 // renderNote uses the repository Markdown policy, then adds local note links.
71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73 return h, err73 return h, err
74 }74 }
75 75
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}91+ pol.source = source
80 // Collect eligible text before the policy unwraps rejected links: their92 // Collect eligible text before the policy unwraps rejected links: their
81 // labels must not accidentally become references either.93 // labels must not accidentally become references either.
82 var refs []*ast.Text94 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163 source []byte175 source []byte
164 // idPrefix is the prefix of the heading ids that #fragment links point at.176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165 idPrefix string177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166 }192 }
167 193
168 func (p linkPolicy) apply(doc ast.Node) {194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195 l.Destination = []byte(dest)221 l.Destination = []byte(dest)
196 l.Title = nil222 l.Title = nil
197 if kind == external {223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))224+ l.SetAttributeString("rel", p.rel())
199 }225 }
200 }226 }
201 for _, n := range autos {227 for _, n := range autos {
202 a := n.(*ast.AutoLink)228 a := n.(*ast.AutoLink)
203 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205 continue235 continue
206 }236 }
207 // An email address or another scheme: the label as plain text.237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242 return "", dropped272 return "", dropped
243 }273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244 if strings.HasPrefix(dest, "#") {277 if strings.HasPrefix(dest, "#") {
245 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246 if frag == "" || strings.ContainsAny(frag, " \"'<>") {279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288 return href, internal321 return href, internal
289 }322 }
290 323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291 func isWebURL(s string) bool {357 func isWebURL(s string) bool {
292 u, err := url.Parse(s)358 u, err := url.Parse(s)
293 if err != nil || u.Host == "" {359 if err != nil || u.Host == "" {
M internal/adapters/web/markdown_test.go +67 -0
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137137 assert.Contains(t, out, `type="checkbox"`)
138138 }
139139
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
206+
140207 func TestNoteMarkdown(t *testing.T) {
141208 t.Parallel()
142209 tests := []struct {
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137 assert.Contains(t, out, `type="checkbox"`)137 assert.Contains(t, out, `type="checkbox"`)
138 }138 }
139 139
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
206+
140 func TestNoteMarkdown(t *testing.T) {207 func TestNoteMarkdown(t *testing.T) {
141 t.Parallel()208 t.Parallel()
142 tests := []struct {209 tests := []struct {
M internal/adapters/web/mr.go +13 -8
@@ -101,12 +101,15 @@ type MRListPage struct {
101101
102102 // CommentView is a comment of a merge request, ready to show.
103103 type CommentView struct {
104- ID string
105- Author string
106- When string
107- WhenAt string
108- Abs string
109- Body, BodySplit template.HTML
104+ ID string
105+ Author string
106+ When string
107+ WhenAt string
108+ Abs string
109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110113 Replies []CommentView
111114 Edited, Deleted bool
112115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156159 // Path, Line and Side designate the line; empty for a general comment.
157160 Path, Line, Side string
158161 StartLine, ReplyTo string
159- Cancel string
160- Focus bool
162+ // Plain is a form of an issue comment: plain text, no verdict.
163+ Plain bool
164+ Cancel string
165+ Focus bool
161166 // From is the window of the page the form is on (see mrWindow), sent
162167 // back so that the answer returns to it; 0 for the first.
163168 From int
@@ -101,12 +101,15 @@ type MRListPage struct {
101 101
102 // CommentView is a comment of a merge request, ready to show.102 // CommentView is a comment of a merge request, ready to show.
103 type CommentView struct {103 type CommentView struct {
104- ID string104+ ID string
105- Author string105+ Author string
106- When string106+ When string
107- WhenAt string107+ WhenAt string
108- Abs string108+ Abs string
109- Body, BodySplit template.HTML109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110 Replies []CommentView113 Replies []CommentView
111 Edited, Deleted bool114 Edited, Deleted bool
112 Verdict, Range, Quote string115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156 // Path, Line and Side designate the line; empty for a general comment.159 // Path, Line and Side designate the line; empty for a general comment.
157 Path, Line, Side string160 Path, Line, Side string
158 StartLine, ReplyTo string161 StartLine, ReplyTo string
159- Cancel string162+ // Plain is a form of an issue comment: plain text, no verdict.
160- Focus bool163+ Plain bool
164+ Cancel string
165+ Focus bool
161 // From is the window of the page the form is on (see mrWindow), sent166 // From is the window of the page the form is on (see mrWindow), sent
162 // back so that the answer returns to it; 0 for the first.167 // back so that the answer returns to it; 0 for the first.
163 From int168 From int
M internal/adapters/web/mr.templ +10 -3
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247247 { " " }<span class="c-where mute">{ c.Where }</span>
248248 }
249249 </p>
250- if c.Deleted {
250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251253 if c.Verdict != "" {
252254 <p class="mute">text deleted</p>
253255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316318 name="text"
317319 rows="3"
318320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"
321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320327 if f.Focus {
321328 autofocus
322329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330337 if f.ReplyTo != "" {
331338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332339 }
333- if f.Path == "" && f.ReplyTo == "" {
340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335342 }
336343 if f.From > 0 {
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247 { " " }<span class="c-where mute">{ c.Where }</span>247 { " " }<span class="c-where mute">{ c.Where }</span>
248 }248 }
249 </p>249 </p>
250- if c.Deleted {250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251 if c.Verdict != "" {253 if c.Verdict != "" {
252 <p class="mute">text deleted</p>254 <p class="mute">text deleted</p>
253 } else {255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316 name="text"318 name="text"
317 rows="3"319 rows="3"
318 maxlength="8000"320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320 if f.Focus {327 if f.Focus {
321 autofocus328 autofocus
322 }329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330 if f.ReplyTo != "" {337 if f.ReplyTo != "" {
331 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332 }339 }
333- if f.Path == "" && f.ReplyTo == "" {340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335 }342 }
336 if f.From > 0 {343 if f.From > 0 {
M internal/adapters/web/mr_post.go +8 -1
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
3131 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
3232 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
3333 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
3542 return false
3643 }
3744 return true
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {33 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
35 return false42 return false
36 }43 }
37 return true44 return true
M internal/adapters/web/mr_templ.go +289 -265 generated file: collapsed
M internal/adapters/web/overlays.templ +1 -1
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
2626 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
3030 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3131 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
3232 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
M internal/adapters/web/overlays_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/pages.templ +8 -0
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
99 <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
1220 <div class="filter">
1321 <label for="home-filter" class="sr">filter</label>
1422 <input
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
9 <div class="home" data-layout="cards">9 <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">10 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">11 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
12 <div class="filter">20 <div class="filter">
13 <label for="home-filter" class="sr">filter</label>21 <label for="home-filter" class="sr">filter</label>
14 <input22 <input
M internal/adapters/web/pages_templ.go +409 -363
@@ -44,89 +44,135 @@ func Home(l Layout, p HomePage) templ.Component {
4444 }()
4545 }
4646 ctx = templ.InitializeContext(ctx)
47- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")
47+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\">")
4848 if templ_7745c5c3_Err != nil {
4949 return templ_7745c5c3_Err
5050 }
51- for _, s := range p.Sorts {
52- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"")
51+ if p.Inbox != nil {
52+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"btn inbox-btn\" href=\"")
5353 if templ_7745c5c3_Err != nil {
5454 return templ_7745c5c3_Err
5555 }
5656 var templ_7745c5c3_Var3 templ.SafeURL
57- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
57+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Inbox.Href))
5858 if templ_7745c5c3_Err != nil {
59- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 27, Col: 36}
59+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 13, Col: 65}
6060 }
6161 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
6262 if templ_7745c5c3_Err != nil {
6363 return templ_7745c5c3_Err
6464 }
65- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
65+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" title=\"inbox\">Inbox ")
66+ if templ_7745c5c3_Err != nil {
67+ return templ_7745c5c3_Err
68+ }
69+ if p.Inbox.Count >= 0 {
70+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"count\">")
71+ if templ_7745c5c3_Err != nil {
72+ return templ_7745c5c3_Err
73+ }
74+ var templ_7745c5c3_Var4 string
75+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Inbox.Count))
76+ if templ_7745c5c3_Err != nil {
77+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 16, Col: 49}
78+ }
79+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
80+ if templ_7745c5c3_Err != nil {
81+ return templ_7745c5c3_Err
82+ }
83+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>")
84+ if templ_7745c5c3_Err != nil {
85+ return templ_7745c5c3_Err
86+ }
87+ }
88+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
89+ if templ_7745c5c3_Err != nil {
90+ return templ_7745c5c3_Err
91+ }
92+ }
93+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")
94+ if templ_7745c5c3_Err != nil {
95+ return templ_7745c5c3_Err
96+ }
97+ for _, s := range p.Sorts {
98+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
99+ if templ_7745c5c3_Err != nil {
100+ return templ_7745c5c3_Err
101+ }
102+ var templ_7745c5c3_Var5 templ.SafeURL
103+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
104+ if templ_7745c5c3_Err != nil {
105+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 36}
106+ }
107+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
108+ if templ_7745c5c3_Err != nil {
109+ return templ_7745c5c3_Err
110+ }
111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
66112 if templ_7745c5c3_Err != nil {
67113 return templ_7745c5c3_Err
68114 }
69115 if s.Mark != "" {
70- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " aria-current=\"true\"")
116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"true\"")
71117 if templ_7745c5c3_Err != nil {
72118 return templ_7745c5c3_Err
73119 }
74120 }
75- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">")
121+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">")
76122 if templ_7745c5c3_Err != nil {
77123 return templ_7745c5c3_Err
78124 }
79- var templ_7745c5c3_Var4 string
80- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
125+ var templ_7745c5c3_Var6 string
126+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
81127 if templ_7745c5c3_Err != nil {
82- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 32, Col: 17}
128+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 40, Col: 17}
83129 }
84- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
130+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
85131 if templ_7745c5c3_Err != nil {
86132 return templ_7745c5c3_Err
87133 }
88- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
89135 if templ_7745c5c3_Err != nil {
90136 return templ_7745c5c3_Err
91137 }
92138 if s.Mark != "" {
93- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span aria-hidden=\"true\">")
139+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span aria-hidden=\"true\">")
94140 if templ_7745c5c3_Err != nil {
95141 return templ_7745c5c3_Err
96142 }
97- var templ_7745c5c3_Var5 string
98- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark)
143+ var templ_7745c5c3_Var7 string
144+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark)
99145 if templ_7745c5c3_Err != nil {
100- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 34, Col: 42}
146+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 42, Col: 42}
101147 }
102- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
148+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
103149 if templ_7745c5c3_Err != nil {
104150 return templ_7745c5c3_Err
105151 }
106- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <span class=\"sr\">")
152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span class=\"sr\">")
107153 if templ_7745c5c3_Err != nil {
108154 return templ_7745c5c3_Err
109155 }
110- var templ_7745c5c3_Var6 string
111- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State)
156+ var templ_7745c5c3_Var8 string
157+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State)
112158 if templ_7745c5c3_Err != nil {
113- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 42}
159+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 43, Col: 42}
114160 }
115- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
161+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
116162 if templ_7745c5c3_Err != nil {
117163 return templ_7745c5c3_Err
118164 }
119- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")
165+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>")
120166 if templ_7745c5c3_Err != nil {
121167 return templ_7745c5c3_Err
122168 }
123169 }
124- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</a>")
170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a>")
125171 if templ_7745c5c3_Err != nil {
126172 return templ_7745c5c3_Err
127173 }
128174 }
129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")
175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")
130176 if templ_7745c5c3_Err != nil {
131177 return templ_7745c5c3_Err
132178 }
@@ -136,17 +182,17 @@ func Home(l Layout, p HomePage) templ.Component {
136182 return templ_7745c5c3_Err
137183 }
138184 }
139- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ul>")
185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</ul>")
140186 if templ_7745c5c3_Err != nil {
141187 return templ_7745c5c3_Err
142188 }
143189 if len(p.Rows) == 0 {
144- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"lede dim\">no repositories yet.</p>")
190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no repositories yet.</p>")
145191 if templ_7745c5c3_Err != nil {
146192 return templ_7745c5c3_Err
147193 }
148194 }
149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</section></div>")
195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section></div>")
150196 if templ_7745c5c3_Err != nil {
151197 return templ_7745c5c3_Err
152198 }
@@ -178,12 +224,12 @@ func Summary(l Layout, p SummaryPage) templ.Component {
178224 }()
179225 }
180226 ctx = templ.InitializeContext(ctx)
181- templ_7745c5c3_Var7 := templ.GetChildren(ctx)
182- if templ_7745c5c3_Var7 == nil {
183- templ_7745c5c3_Var7 = templ.NopComponent
227+ templ_7745c5c3_Var9 := templ.GetChildren(ctx)
228+ if templ_7745c5c3_Var9 == nil {
229+ templ_7745c5c3_Var9 = templ.NopComponent
184230 }
185231 ctx = templ.ClearChildren(ctx)
186- templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
232+ templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
187233 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
188234 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
189235 if !templ_7745c5c3_IsBuffer {
@@ -203,7 +249,7 @@ func Summary(l Layout, p SummaryPage) templ.Component {
203249 }
204250 return nil
205251 })
206- templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)
252+ templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
207253 if templ_7745c5c3_Err != nil {
208254 return templ_7745c5c3_Err
209255 }
@@ -230,158 +276,158 @@ func latestTagFact(t LatestTag) templ.Component {
230276 }()
231277 }
232278 ctx = templ.InitializeContext(ctx)
233- templ_7745c5c3_Var9 := templ.GetChildren(ctx)
234- if templ_7745c5c3_Var9 == nil {
235- templ_7745c5c3_Var9 = templ.NopComponent
279+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
280+ if templ_7745c5c3_Var11 == nil {
281+ templ_7745c5c3_Var11 = templ.NopComponent
236282 }
237283 ctx = templ.ClearChildren(ctx)
238- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<dt>latest tag</dt><dd class=\"latest-tag\">")
284+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>latest tag</dt><dd class=\"latest-tag\">")
239285 if templ_7745c5c3_Err != nil {
240286 return templ_7745c5c3_Err
241287 }
242288 if t.Name != "" {
243- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a class=\"tag-name\" href=\"")
289+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"tag-name\" href=\"")
244290 if templ_7745c5c3_Err != nil {
245291 return templ_7745c5c3_Err
246292 }
247- var templ_7745c5c3_Var10 templ.SafeURL
248- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))
293+ var templ_7745c5c3_Var12 templ.SafeURL
294+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))
249295 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 51}
296+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 51}
251297 }
252- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
298+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
253299 if templ_7745c5c3_Err != nil {
254300 return templ_7745c5c3_Err
255301 }
256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" title=\"")
302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" title=\"")
257303 if templ_7745c5c3_Err != nil {
258304 return templ_7745c5c3_Err
259305 }
260- var templ_7745c5c3_Var11 string
261- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)
306+ var templ_7745c5c3_Var13 string
307+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)
262308 if templ_7745c5c3_Err != nil {
263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 71}
309+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 71}
264310 }
265- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
311+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
266312 if templ_7745c5c3_Err != nil {
267313 return templ_7745c5c3_Err
268314 }
269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
315+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
270316 if templ_7745c5c3_Err != nil {
271317 return templ_7745c5c3_Err
272318 }
273- var templ_7745c5c3_Var12 string
274- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
319+ var templ_7745c5c3_Var14 string
320+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
275321 if templ_7745c5c3_Err != nil {
276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 82}
322+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 82}
277323 }
278- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
324+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
279325 if templ_7745c5c3_Err != nil {
280326 return templ_7745c5c3_Err
281327 }
282- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> ")
328+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</a> ")
283329 if templ_7745c5c3_Err != nil {
284330 return templ_7745c5c3_Err
285331 }
286332 if t.WhenAt != "" {
287- var templ_7745c5c3_Var13 string
288- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
333+ var templ_7745c5c3_Var15 string
334+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
289335 if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 12}
336+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 12}
291337 }
292- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
338+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
293339 if templ_7745c5c3_Err != nil {
294340 return templ_7745c5c3_Err
295341 }
296- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<time datetime=\"")
342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<time datetime=\"")
297343 if templ_7745c5c3_Err != nil {
298344 return templ_7745c5c3_Err
299345 }
300- var templ_7745c5c3_Var14 string
301- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
346+ var templ_7745c5c3_Var16 string
347+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
302348 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 39}
349+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 39}
304350 }
305- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
351+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
306352 if templ_7745c5c3_Err != nil {
307353 return templ_7745c5c3_Err
308354 }
309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" title=\"")
355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"")
310356 if templ_7745c5c3_Err != nil {
311357 return templ_7745c5c3_Err
312358 }
313- var templ_7745c5c3_Var15 string
314- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
359+ var templ_7745c5c3_Var17 string
360+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
315361 if templ_7745c5c3_Err != nil {
316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 58}
362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 58}
317363 }
318- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
319365 if templ_7745c5c3_Err != nil {
320366 return templ_7745c5c3_Err
321367 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
323369 if templ_7745c5c3_Err != nil {
324370 return templ_7745c5c3_Err
325371 }
326- var templ_7745c5c3_Var16 string
327- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)
372+ var templ_7745c5c3_Var18 string
373+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)
328374 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 69}
375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 69}
330376 }
331- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
377+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
332378 if templ_7745c5c3_Err != nil {
333379 return templ_7745c5c3_Err
334380 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</time>")
381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time>")
336382 if templ_7745c5c3_Err != nil {
337383 return templ_7745c5c3_Err
338384 }
339385 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ")
386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ")
341387 if templ_7745c5c3_Err != nil {
342388 return templ_7745c5c3_Err
343389 }
344- var templ_7745c5c3_Var17 string
345- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
390+ var templ_7745c5c3_Var19 string
391+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
346392 if templ_7745c5c3_Err != nil {
347- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 11}
393+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 94, Col: 11}
348394 }
349- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
395+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
350396 if templ_7745c5c3_Err != nil {
351397 return templ_7745c5c3_Err
352398 }
353- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ")
399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ")
354400 if templ_7745c5c3_Err != nil {
355401 return templ_7745c5c3_Err
356402 }
357403 }
358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"all-tags\" href=\"")
404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"all-tags\" href=\"")
359405 if templ_7745c5c3_Err != nil {
360406 return templ_7745c5c3_Err
361407 }
362- var templ_7745c5c3_Var18 templ.SafeURL
363- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))
408+ var templ_7745c5c3_Var20 templ.SafeURL
409+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))
364410 if templ_7745c5c3_Err != nil {
365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 54}
411+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 54}
366412 }
367- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
413+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
368414 if templ_7745c5c3_Err != nil {
369415 return templ_7745c5c3_Err
370416 }
371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")
417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
372418 if templ_7745c5c3_Err != nil {
373419 return templ_7745c5c3_Err
374420 }
375- var templ_7745c5c3_Var19 string
376- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))
421+ var templ_7745c5c3_Var21 string
422+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))
377423 if templ_7745c5c3_Err != nil {
378- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 89}
424+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 89}
379425 }
380- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
426+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
381427 if templ_7745c5c3_Err != nil {
382428 return templ_7745c5c3_Err
383429 }
384- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a></dd>")
430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a></dd>")
385431 if templ_7745c5c3_Err != nil {
386432 return templ_7745c5c3_Err
387433 }
@@ -405,12 +451,12 @@ func summaryRail(p SummaryPage) templ.Component {
405451 }()
406452 }
407453 ctx = templ.InitializeContext(ctx)
408- templ_7745c5c3_Var20 := templ.GetChildren(ctx)
409- if templ_7745c5c3_Var20 == nil {
410- templ_7745c5c3_Var20 = templ.NopComponent
454+ templ_7745c5c3_Var22 := templ.GetChildren(ctx)
455+ if templ_7745c5c3_Var22 == nil {
456+ templ_7745c5c3_Var22 = templ.NopComponent
411457 }
412458 ctx = templ.ClearChildren(ctx)
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>")
459+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>")
414460 if templ_7745c5c3_Err != nil {
415461 return templ_7745c5c3_Err
416462 }
@@ -418,25 +464,25 @@ func summaryRail(p SummaryPage) templ.Component {
418464 if templ_7745c5c3_Err != nil {
419465 return templ_7745c5c3_Err
420466 }
421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">")
467+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">")
422468 if templ_7745c5c3_Err != nil {
423469 return templ_7745c5c3_Err
424470 }
425471 for _, f := range p.Facts {
426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<dt>")
472+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<dt>")
427473 if templ_7745c5c3_Err != nil {
428474 return templ_7745c5c3_Err
429475 }
430- var templ_7745c5c3_Var21 string
431- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)
476+ var templ_7745c5c3_Var23 string
477+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)
432478 if templ_7745c5c3_Err != nil {
433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 99, Col: 15}
479+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 107, Col: 15}
434480 }
435- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
481+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
436482 if templ_7745c5c3_Err != nil {
437483 return templ_7745c5c3_Err
438484 }
439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</dt>")
485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</dt>")
440486 if templ_7745c5c3_Err != nil {
441487 return templ_7745c5c3_Err
442488 }
@@ -451,25 +497,25 @@ func summaryRail(p SummaryPage) templ.Component {
451497 return templ_7745c5c3_Err
452498 }
453499 }
454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</dl></section>")
500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</dl></section>")
455501 if templ_7745c5c3_Err != nil {
456502 return templ_7745c5c3_Err
457503 }
458504 if len(p.Recent) > 0 {
459- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
460506 if templ_7745c5c3_Err != nil {
461507 return templ_7745c5c3_Err
462508 }
463- var templ_7745c5c3_Var22 templ.SafeURL
464- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
509+ var templ_7745c5c3_Var24 templ.SafeURL
510+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
465511 if templ_7745c5c3_Err != nil {
466- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 108, Col: 105}
512+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 116, Col: 105}
467513 }
468- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
514+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
469515 if templ_7745c5c3_Err != nil {
470516 return templ_7745c5c3_Err
471517 }
472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">View all</a></h2>")
518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">View all</a></h2>")
473519 if templ_7745c5c3_Err != nil {
474520 return templ_7745c5c3_Err
475521 }
@@ -477,7 +523,7 @@ func summaryRail(p SummaryPage) templ.Component {
477523 if templ_7745c5c3_Err != nil {
478524 return templ_7745c5c3_Err
479525 }
480- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>")
526+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</section>")
481527 if templ_7745c5c3_Err != nil {
482528 return templ_7745c5c3_Err
483529 }
@@ -489,7 +535,7 @@ func summaryRail(p SummaryPage) templ.Component {
489535 }
490536 }
491537 if len(p.Outline) > 0 && p.Readme != nil {
492- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
493539 if templ_7745c5c3_Err != nil {
494540 return templ_7745c5c3_Err
495541 }
@@ -497,7 +543,7 @@ func summaryRail(p SummaryPage) templ.Component {
497543 if templ_7745c5c3_Err != nil {
498544 return templ_7745c5c3_Err
499545 }
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section>")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>")
501547 if templ_7745c5c3_Err != nil {
502548 return templ_7745c5c3_Err
503549 }
@@ -523,12 +569,12 @@ func Refs(l Layout, p RefsPage) templ.Component {
523569 }()
524570 }
525571 ctx = templ.InitializeContext(ctx)
526- templ_7745c5c3_Var23 := templ.GetChildren(ctx)
527- if templ_7745c5c3_Var23 == nil {
528- templ_7745c5c3_Var23 = templ.NopComponent
572+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
573+ if templ_7745c5c3_Var25 == nil {
574+ templ_7745c5c3_Var25 = templ.NopComponent
529575 }
530576 ctx = templ.ClearChildren(ctx)
531- templ_7745c5c3_Var24 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
577+ templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
532578 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
533579 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
534580 if !templ_7745c5c3_IsBuffer {
@@ -540,7 +586,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
540586 }()
541587 }
542588 ctx = templ.InitializeContext(ctx)
543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<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>")
589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<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>")
544590 if templ_7745c5c3_Err != nil {
545591 return templ_7745c5c3_Err
546592 }
@@ -548,32 +594,32 @@ func Refs(l Layout, p RefsPage) templ.Component {
548594 if templ_7745c5c3_Err != nil {
549595 return templ_7745c5c3_Err
550596 }
551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</table>")
597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</table>")
552598 if templ_7745c5c3_Err != nil {
553599 return templ_7745c5c3_Err
554600 }
555601 if p.Note != "" {
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"note\">")
602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<p class=\"note\">")
557603 if templ_7745c5c3_Err != nil {
558604 return templ_7745c5c3_Err
559605 }
560- var templ_7745c5c3_Var25 string
561- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
606+ var templ_7745c5c3_Var27 string
607+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
562608 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 141, Col: 27}
609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 149, Col: 27}
564610 }
565- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
566612 if templ_7745c5c3_Err != nil {
567613 return templ_7745c5c3_Err
568614 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p>")
570616 if templ_7745c5c3_Err != nil {
571617 return templ_7745c5c3_Err
572618 }
573619 }
574620 return nil
575621 })
576- templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var24), templ_7745c5c3_Buffer)
622+ templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer)
577623 if templ_7745c5c3_Err != nil {
578624 return templ_7745c5c3_Err
579625 }
@@ -597,112 +643,112 @@ func refRow(kind string, r RefRow) templ.Component {
597643 }()
598644 }
599645 ctx = templ.InitializeContext(ctx)
600- templ_7745c5c3_Var26 := templ.GetChildren(ctx)
601- if templ_7745c5c3_Var26 == nil {
602- templ_7745c5c3_Var26 = templ.NopComponent
646+ templ_7745c5c3_Var28 := templ.GetChildren(ctx)
647+ if templ_7745c5c3_Var28 == nil {
648+ templ_7745c5c3_Var28 = templ.NopComponent
603649 }
604650 ctx = templ.ClearChildren(ctx)
605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<tr")
651+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<tr")
606652 if templ_7745c5c3_Err != nil {
607653 return templ_7745c5c3_Err
608654 }
609655 if r.Cursor {
610- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " data-cursor")
656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-cursor")
611657 if templ_7745c5c3_Err != nil {
612658 return templ_7745c5c3_Err
613659 }
614660 }
615- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"c-kind mute\">")
661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><td class=\"c-kind mute\">")
616662 if templ_7745c5c3_Err != nil {
617663 return templ_7745c5c3_Err
618664 }
619- var templ_7745c5c3_Var27 string
620- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
665+ var templ_7745c5c3_Var29 string
666+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
621667 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 32}
668+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 32}
623669 }
624- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
670+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
625671 if templ_7745c5c3_Err != nil {
626672 return templ_7745c5c3_Err
627673 }
628- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</td><td class=\"c-name\"><a href=\"")
674+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-name\"><a href=\"")
629675 if templ_7745c5c3_Err != nil {
630676 return templ_7745c5c3_Err
631677 }
632- var templ_7745c5c3_Var28 templ.SafeURL
633- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
678+ var templ_7745c5c3_Var30 templ.SafeURL
679+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
634680 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 34}
681+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 34}
636682 }
637- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
683+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
638684 if templ_7745c5c3_Err != nil {
639685 return templ_7745c5c3_Err
640686 }
641- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
687+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
642688 if templ_7745c5c3_Err != nil {
643689 return templ_7745c5c3_Err
644690 }
645- var templ_7745c5c3_Var29 string
646- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
691+ var templ_7745c5c3_Var31 string
692+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
647693 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 45}
694+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 45}
649695 }
650- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
696+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
651697 if templ_7745c5c3_Err != nil {
652698 return templ_7745c5c3_Err
653699 }
654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a> ")
700+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> ")
655701 if templ_7745c5c3_Err != nil {
656702 return templ_7745c5c3_Err
657703 }
658704 if r.Default {
659- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"pill\">default</span>")
705+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"pill\">default</span>")
660706 if templ_7745c5c3_Err != nil {
661707 return templ_7745c5c3_Err
662708 }
663709 }
664- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-sha\">")
710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-sha\">")
665711 if templ_7745c5c3_Err != nil {
666712 return templ_7745c5c3_Err
667713 }
668714 if r.CHref != "" {
669- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"")
715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"")
670716 if templ_7745c5c3_Err != nil {
671717 return templ_7745c5c3_Err
672718 }
673- var templ_7745c5c3_Var30 templ.SafeURL
674- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
719+ var templ_7745c5c3_Var32 templ.SafeURL
720+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
675721 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 36}
722+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 36}
677723 }
678- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
724+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
679725 if templ_7745c5c3_Err != nil {
680726 return templ_7745c5c3_Err
681727 }
682- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">")
728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")
683729 if templ_7745c5c3_Err != nil {
684730 return templ_7745c5c3_Err
685731 }
686- var templ_7745c5c3_Var31 string
687- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
732+ var templ_7745c5c3_Var33 string
733+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
688734 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 48}
735+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 48}
690736 }
691- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
737+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
692738 if templ_7745c5c3_Err != nil {
693739 return templ_7745c5c3_Err
694740 }
695- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")
741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</a>")
696742 if templ_7745c5c3_Err != nil {
697743 return templ_7745c5c3_Err
698744 }
699745 } else {
700- var templ_7745c5c3_Var32 string
701- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
746+ var templ_7745c5c3_Var34 string
747+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
702748 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 13}
749+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 167, Col: 13}
704750 }
705- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
751+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
706752 if templ_7745c5c3_Err != nil {
707753 return templ_7745c5c3_Err
708754 }
@@ -711,69 +757,69 @@ func refRow(kind string, r RefRow) templ.Component {
711757 if templ_7745c5c3_Err != nil {
712758 return templ_7745c5c3_Err
713759 }
714- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-sub\">")
760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td><td class=\"c-sub\">")
715761 if templ_7745c5c3_Err != nil {
716762 return templ_7745c5c3_Err
717763 }
718- var templ_7745c5c3_Var33 string
719- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
764+ var templ_7745c5c3_Var35 string
765+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
720766 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 31}
767+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 31}
722768 }
723- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
769+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
724770 if templ_7745c5c3_Err != nil {
725771 return templ_7745c5c3_Err
726772 }
727- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-when dim\">")
773+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"c-when dim\">")
728774 if templ_7745c5c3_Err != nil {
729775 return templ_7745c5c3_Err
730776 }
731777 if r.WhenAt != "" {
732- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<time datetime=\"")
778+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<time datetime=\"")
733779 if templ_7745c5c3_Err != nil {
734780 return templ_7745c5c3_Err
735781 }
736- var templ_7745c5c3_Var34 string
737- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
782+ var templ_7745c5c3_Var36 string
783+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
738784 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 29}
785+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 29}
740786 }
741- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
787+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
742788 if templ_7745c5c3_Err != nil {
743789 return templ_7745c5c3_Err
744790 }
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" title=\"")
791+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"")
746792 if templ_7745c5c3_Err != nil {
747793 return templ_7745c5c3_Err
748794 }
749- var templ_7745c5c3_Var35 string
750- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
795+ var templ_7745c5c3_Var37 string
796+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
751797 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48}
798+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 48}
753799 }
754- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
800+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
755801 if templ_7745c5c3_Err != nil {
756802 return templ_7745c5c3_Err
757803 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")
804+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
759805 if templ_7745c5c3_Err != nil {
760806 return templ_7745c5c3_Err
761807 }
762- var templ_7745c5c3_Var36 string
763- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
808+ var templ_7745c5c3_Var38 string
809+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
764810 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 59}
811+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 59}
766812 }
767- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
813+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
768814 if templ_7745c5c3_Err != nil {
769815 return templ_7745c5c3_Err
770816 }
771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</time>")
817+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</time>")
772818 if templ_7745c5c3_Err != nil {
773819 return templ_7745c5c3_Err
774820 }
775821 }
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td></tr>")
822+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td></tr>")
777823 if templ_7745c5c3_Err != nil {
778824 return templ_7745c5c3_Err
779825 }
@@ -797,12 +843,12 @@ func refsRail(p RefsPage) templ.Component {
797843 }()
798844 }
799845 ctx = templ.InitializeContext(ctx)
800- templ_7745c5c3_Var37 := templ.GetChildren(ctx)
801- if templ_7745c5c3_Var37 == nil {
802- templ_7745c5c3_Var37 = templ.NopComponent
846+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
847+ if templ_7745c5c3_Var39 == nil {
848+ templ_7745c5c3_Var39 = templ.NopComponent
803849 }
804850 ctx = templ.ClearChildren(ctx)
805- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
851+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
806852 if templ_7745c5c3_Err != nil {
807853 return templ_7745c5c3_Err
808854 }
@@ -810,7 +856,7 @@ func refsRail(p RefsPage) templ.Component {
810856 if templ_7745c5c3_Err != nil {
811857 return templ_7745c5c3_Err
812858 }
813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</section>")
859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</section>")
814860 if templ_7745c5c3_Err != nil {
815861 return templ_7745c5c3_Err
816862 }
@@ -835,12 +881,12 @@ func State(l Layout, p StatePage) templ.Component {
835881 }()
836882 }
837883 ctx = templ.InitializeContext(ctx)
838- templ_7745c5c3_Var38 := templ.GetChildren(ctx)
839- if templ_7745c5c3_Var38 == nil {
840- templ_7745c5c3_Var38 = templ.NopComponent
884+ templ_7745c5c3_Var40 := templ.GetChildren(ctx)
885+ if templ_7745c5c3_Var40 == nil {
886+ templ_7745c5c3_Var40 = templ.NopComponent
841887 }
842888 ctx = templ.ClearChildren(ctx)
843- templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
889+ templ_7745c5c3_Var41 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
844890 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
845891 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
846892 if !templ_7745c5c3_IsBuffer {
@@ -852,51 +898,51 @@ func State(l Layout, p StatePage) templ.Component {
852898 }()
853899 }
854900 ctx = templ.InitializeContext(ctx)
855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
901+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
856902 if templ_7745c5c3_Err != nil {
857903 return templ_7745c5c3_Err
858904 }
859- var templ_7745c5c3_Var40 string
860- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
905+ var templ_7745c5c3_Var42 string
906+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
861907 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 30}
908+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 30}
863909 }
864- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
910+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
865911 if templ_7745c5c3_Err != nil {
866912 return templ_7745c5c3_Err
867913 }
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</p><h1 id=\"state-title\">")
914+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</p><h1 id=\"state-title\">")
869915 if templ_7745c5c3_Err != nil {
870916 return templ_7745c5c3_Err
871917 }
872- var templ_7745c5c3_Var41 string
873- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
918+ var templ_7745c5c3_Var43 string
919+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
874920 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 33}
921+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 192, Col: 33}
876922 }
877- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
923+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
878924 if templ_7745c5c3_Err != nil {
879925 return templ_7745c5c3_Err
880926 }
881- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</h1>")
927+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</h1>")
882928 if templ_7745c5c3_Err != nil {
883929 return templ_7745c5c3_Err
884930 }
885931 for _, line := range p.Lines {
886- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<p class=\"dim\">")
932+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"dim\">")
887933 if templ_7745c5c3_Err != nil {
888934 return templ_7745c5c3_Err
889935 }
890- var templ_7745c5c3_Var42 string
891- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)
936+ var templ_7745c5c3_Var44 string
937+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(line)
892938 if templ_7745c5c3_Err != nil {
893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 186, Col: 25}
939+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 25}
894940 }
895- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
941+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
896942 if templ_7745c5c3_Err != nil {
897943 return templ_7745c5c3_Err
898944 }
899- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</p>")
945+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p>")
900946 if templ_7745c5c3_Err != nil {
901947 return templ_7745c5c3_Err
902948 }
@@ -907,49 +953,49 @@ func State(l Layout, p StatePage) templ.Component {
907953 return templ_7745c5c3_Err
908954 }
909955 }
910- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"state-back\">")
956+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<p class=\"state-back\">")
911957 if templ_7745c5c3_Err != nil {
912958 return templ_7745c5c3_Err
913959 }
914960 for _, b := range p.Back {
915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<a href=\"")
961+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<a href=\"")
916962 if templ_7745c5c3_Err != nil {
917963 return templ_7745c5c3_Err
918964 }
919- var templ_7745c5c3_Var43 templ.SafeURL
920- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
965+ var templ_7745c5c3_Var45 templ.SafeURL
966+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
921967 if templ_7745c5c3_Err != nil {
922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 36}
968+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 36}
923969 }
924- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
970+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
925971 if templ_7745c5c3_Err != nil {
926972 return templ_7745c5c3_Err
927973 }
928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">")
974+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\">")
929975 if templ_7745c5c3_Err != nil {
930976 return templ_7745c5c3_Err
931977 }
932- var templ_7745c5c3_Var44 string
933- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
978+ var templ_7745c5c3_Var46 string
979+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
934980 if templ_7745c5c3_Err != nil {
935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 48}
981+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 48}
936982 }
937- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
983+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
938984 if templ_7745c5c3_Err != nil {
939985 return templ_7745c5c3_Err
940986 }
941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</a>")
987+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</a>")
942988 if templ_7745c5c3_Err != nil {
943989 return templ_7745c5c3_Err
944990 }
945991 }
946- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</p></section>")
992+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</p></section>")
947993 if templ_7745c5c3_Err != nil {
948994 return templ_7745c5c3_Err
949995 }
950996 return nil
951997 })
952- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer)
998+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var41), templ_7745c5c3_Buffer)
953999 if templ_7745c5c3_Err != nil {
9541000 return templ_7745c5c3_Err
9551001 }
@@ -978,12 +1024,12 @@ func Login(l Layout, p LoginPage) templ.Component {
9781024 }()
9791025 }
9801026 ctx = templ.InitializeContext(ctx)
981- templ_7745c5c3_Var45 := templ.GetChildren(ctx)
982- if templ_7745c5c3_Var45 == nil {
983- templ_7745c5c3_Var45 = templ.NopComponent
1027+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
1028+ if templ_7745c5c3_Var47 == nil {
1029+ templ_7745c5c3_Var47 = templ.NopComponent
9841030 }
9851031 ctx = templ.ClearChildren(ctx)
986- templ_7745c5c3_Var46 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1032+ templ_7745c5c3_Var48 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
9871033 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
9881034 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
9891035 if !templ_7745c5c3_IsBuffer {
@@ -995,75 +1041,75 @@ func Login(l Layout, p LoginPage) templ.Component {
9951041 }()
9961042 }
9971043 ctx = templ.InitializeContext(ctx)
998- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1044+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
9991045 if templ_7745c5c3_Err != nil {
10001046 return templ_7745c5c3_Err
10011047 }
1002- var templ_7745c5c3_Var47 string
1003- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1048+ var templ_7745c5c3_Var49 string
1049+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
10041050 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 33}
1051+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 33}
10061052 }
1007- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
1053+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
10081054 if templ_7745c5c3_Err != nil {
10091055 return templ_7745c5c3_Err
10101056 }
1011- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</h1>")
1057+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</h1>")
10121058 if templ_7745c5c3_Err != nil {
10131059 return templ_7745c5c3_Err
10141060 }
10151061 for _, line := range p.Lines {
1016- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"dim\">")
1062+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"dim\">")
10171063 if templ_7745c5c3_Err != nil {
10181064 return templ_7745c5c3_Err
10191065 }
1020- var templ_7745c5c3_Var48 string
1021- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1066+ var templ_7745c5c3_Var50 string
1067+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(line)
10221068 if templ_7745c5c3_Err != nil {
1023- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 210, Col: 25}
1069+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 25}
10241070 }
1025- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1071+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
10261072 if templ_7745c5c3_Err != nil {
10271073 return templ_7745c5c3_Err
10281074 }
1029- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</p>")
1075+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</p>")
10301076 if templ_7745c5c3_Err != nil {
10311077 return templ_7745c5c3_Err
10321078 }
10331079 }
1034- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<form class=\"state-act\" method=\"post\" action=\"")
1080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form class=\"state-act\" method=\"post\" action=\"")
10351081 if templ_7745c5c3_Err != nil {
10361082 return templ_7745c5c3_Err
10371083 }
1038- var templ_7745c5c3_Var49 templ.SafeURL
1039- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1084+ var templ_7745c5c3_Var51 templ.SafeURL
1085+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
10401086 if templ_7745c5c3_Err != nil {
1041- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 212, Col: 73}
1087+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 73}
10421088 }
1043- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1089+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
10441090 if templ_7745c5c3_Err != nil {
10451091 return templ_7745c5c3_Err
10461092 }
1047- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
1093+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
10481094 if templ_7745c5c3_Err != nil {
10491095 return templ_7745c5c3_Err
10501096 }
1051- var templ_7745c5c3_Var50 string
1052- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1097+ var templ_7745c5c3_Var52 string
1098+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
10531099 if templ_7745c5c3_Err != nil {
1054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 213, Col: 56}
1100+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 56}
10551101 }
1056- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1102+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
10571103 if templ_7745c5c3_Err != nil {
10581104 return templ_7745c5c3_Err
10591105 }
1060- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
10611107 if templ_7745c5c3_Err != nil {
10621108 return templ_7745c5c3_Err
10631109 }
10641110 return nil
10651111 })
1066- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var46), templ_7745c5c3_Buffer)
1112+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var48), templ_7745c5c3_Buffer)
10671113 if templ_7745c5c3_Err != nil {
10681114 return templ_7745c5c3_Err
10691115 }
@@ -1089,12 +1135,12 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
10891135 }()
10901136 }
10911137 ctx = templ.InitializeContext(ctx)
1092- templ_7745c5c3_Var51 := templ.GetChildren(ctx)
1093- if templ_7745c5c3_Var51 == nil {
1094- templ_7745c5c3_Var51 = templ.NopComponent
1138+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1139+ if templ_7745c5c3_Var53 == nil {
1140+ templ_7745c5c3_Var53 = templ.NopComponent
10951141 }
10961142 ctx = templ.ClearChildren(ctx)
1097- templ_7745c5c3_Var52 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1143+ templ_7745c5c3_Var54 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
10981144 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
10991145 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
11001146 if !templ_7745c5c3_IsBuffer {
@@ -1106,88 +1152,88 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11061152 }()
11071153 }
11081154 ctx = templ.InitializeContext(ctx)
1109- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
11101156 if templ_7745c5c3_Err != nil {
11111157 return templ_7745c5c3_Err
11121158 }
1113- var templ_7745c5c3_Var53 string
1114- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1159+ var templ_7745c5c3_Var55 string
1160+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
11151161 if templ_7745c5c3_Err != nil {
1116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 33}
1162+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 33}
11171163 }
1118- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1164+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
11191165 if templ_7745c5c3_Err != nil {
11201166 return templ_7745c5c3_Err
11211167 }
1122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
1168+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
11231169 if templ_7745c5c3_Err != nil {
11241170 return templ_7745c5c3_Err
11251171 }
1126- var templ_7745c5c3_Var54 string
1127- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1172+ var templ_7745c5c3_Var56 string
1173+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
11281174 if templ_7745c5c3_Err != nil {
1129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 87}
1175+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 87}
11301176 }
1131- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1177+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
11321178 if templ_7745c5c3_Err != nil {
11331179 return templ_7745c5c3_Err
11341180 }
1135- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-finish=\"")
1181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-finish=\"")
11361182 if templ_7745c5c3_Err != nil {
11371183 return templ_7745c5c3_Err
11381184 }
1139- var templ_7745c5c3_Var55 string
1140- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1185+ var templ_7745c5c3_Var57 string
1186+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
11411187 if templ_7745c5c3_Err != nil {
1142- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 112}
1188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 112}
11431189 }
1144- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
1190+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
11451191 if templ_7745c5c3_Err != nil {
11461192 return templ_7745c5c3_Err
11471193 }
1148- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\">")
1194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">")
11491195 if templ_7745c5c3_Err != nil {
11501196 return templ_7745c5c3_Err
11511197 }
1152- var templ_7745c5c3_Var56 string
1153- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1198+ var templ_7745c5c3_Var58 string
1199+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
11541200 if templ_7745c5c3_Err != nil {
1155- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 125}
1201+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 125}
11561202 }
1157- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1203+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
11581204 if templ_7745c5c3_Err != nil {
11591205 return templ_7745c5c3_Err
11601206 }
1161- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
11621208 if templ_7745c5c3_Err != nil {
11631209 return templ_7745c5c3_Err
11641210 }
11651211 for _, line := range p.Lines {
1166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"dim\">")
1212+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<p class=\"dim\">")
11671213 if templ_7745c5c3_Err != nil {
11681214 return templ_7745c5c3_Err
11691215 }
1170- var templ_7745c5c3_Var57 string
1171- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1216+ var templ_7745c5c3_Var59 string
1217+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(line)
11721218 if templ_7745c5c3_Err != nil {
1173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}
1219+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 239, Col: 25}
11741220 }
1175- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1221+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
11761222 if templ_7745c5c3_Err != nil {
11771223 return templ_7745c5c3_Err
11781224 }
1179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p>")
1225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p>")
11801226 if templ_7745c5c3_Err != nil {
11811227 return templ_7745c5c3_Err
11821228 }
11831229 }
1184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1230+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
11851231 if templ_7745c5c3_Err != nil {
11861232 return templ_7745c5c3_Err
11871233 }
11881234 return nil
11891235 })
1190- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var52), templ_7745c5c3_Buffer)
1236+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), templ_7745c5c3_Buffer)
11911237 if templ_7745c5c3_Err != nil {
11921238 return templ_7745c5c3_Err
11931239 }
@@ -1212,12 +1258,12 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12121258 }()
12131259 }
12141260 ctx = templ.InitializeContext(ctx)
1215- templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1216- if templ_7745c5c3_Var58 == nil {
1217- templ_7745c5c3_Var58 = templ.NopComponent
1261+ templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1262+ if templ_7745c5c3_Var60 == nil {
1263+ templ_7745c5c3_Var60 = templ.NopComponent
12181264 }
12191265 ctx = templ.ClearChildren(ctx)
1220- templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1266+ templ_7745c5c3_Var61 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
12211267 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
12221268 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
12231269 if !templ_7745c5c3_IsBuffer {
@@ -1229,231 +1275,231 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12291275 }()
12301276 }
12311277 ctx = templ.InitializeContext(ctx)
1232- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
12331279 if templ_7745c5c3_Err != nil {
12341280 return templ_7745c5c3_Err
12351281 }
1236- var templ_7745c5c3_Var60 string
1237- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1282+ var templ_7745c5c3_Var62 string
1283+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
12381284 if templ_7745c5c3_Err != nil {
1239- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 241, Col: 81}
1285+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 81}
12401286 }
1241- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1287+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
12421288 if templ_7745c5c3_Err != nil {
12431289 return templ_7745c5c3_Err
12441290 }
1245- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</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>")
1291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</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>")
12461292 if templ_7745c5c3_Err != nil {
12471293 return templ_7745c5c3_Err
12481294 }
12491295 if len(p.Rows) > 0 {
1250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<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>")
1296+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<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>")
12511297 if templ_7745c5c3_Err != nil {
12521298 return templ_7745c5c3_Err
12531299 }
12541300 for _, r := range p.Rows {
1255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"c-name\">")
1301+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<tr><td class=\"c-name\">")
12561302 if templ_7745c5c3_Err != nil {
12571303 return templ_7745c5c3_Err
12581304 }
1259- var templ_7745c5c3_Var61 string
1260- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1305+ var templ_7745c5c3_Var63 string
1306+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
12611307 if templ_7745c5c3_Err != nil {
1262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 34}
1308+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 34}
12631309 }
1264- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1310+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
12651311 if templ_7745c5c3_Err != nil {
12661312 return templ_7745c5c3_Err
12671313 }
1268- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</td><td class=\"c-when nowrap\"><time")
1314+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</td><td class=\"c-when nowrap\"><time")
12691315 if templ_7745c5c3_Err != nil {
12701316 return templ_7745c5c3_Err
12711317 }
12721318 if r.CreatedAt != "" {
1273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " datetime=\"")
1319+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " datetime=\"")
12741320 if templ_7745c5c3_Err != nil {
12751321 return templ_7745c5c3_Err
12761322 }
1277- var templ_7745c5c3_Var62 string
1278- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1323+ var templ_7745c5c3_Var64 string
1324+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
12791325 if templ_7745c5c3_Err != nil {
1280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 32}
1326+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 32}
12811327 }
1282- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
1328+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
12831329 if templ_7745c5c3_Err != nil {
12841330 return templ_7745c5c3_Err
12851331 }
1286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\"")
1332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"")
12871333 if templ_7745c5c3_Err != nil {
12881334 return templ_7745c5c3_Err
12891335 }
12901336 }
1291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, ">")
1337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, ">")
12921338 if templ_7745c5c3_Err != nil {
12931339 return templ_7745c5c3_Err
12941340 }
1295- var templ_7745c5c3_Var63 string
1296- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1341+ var templ_7745c5c3_Var65 string
1342+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
12971343 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 263, Col: 20}
1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 271, Col: 20}
12991345 }
1300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13011347 if templ_7745c5c3_Err != nil {
13021348 return templ_7745c5c3_Err
13031349 }
1304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</time></td><td class=\"c-when nowrap\"><time")
1350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</time></td><td class=\"c-when nowrap\"><time")
13051351 if templ_7745c5c3_Err != nil {
13061352 return templ_7745c5c3_Err
13071353 }
13081354 if r.LastUsedAt != "" {
1309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " datetime=\"")
1355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " datetime=\"")
13101356 if templ_7745c5c3_Err != nil {
13111357 return templ_7745c5c3_Err
13121358 }
1313- var templ_7745c5c3_Var64 string
1314- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1359+ var templ_7745c5c3_Var66 string
1360+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
13151361 if templ_7745c5c3_Err != nil {
1316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 268, Col: 33}
1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 276, Col: 33}
13171363 }
1318- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
13191365 if templ_7745c5c3_Err != nil {
13201366 return templ_7745c5c3_Err
13211367 }
1322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"")
1368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"")
13231369 if templ_7745c5c3_Err != nil {
13241370 return templ_7745c5c3_Err
13251371 }
13261372 }
1327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, ">")
1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ">")
13281374 if templ_7745c5c3_Err != nil {
13291375 return templ_7745c5c3_Err
13301376 }
1331- var templ_7745c5c3_Var65 string
1332- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1377+ var templ_7745c5c3_Var67 string
1378+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
13331379 if templ_7745c5c3_Err != nil {
1334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 21}
1380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 21}
13351381 }
1336- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
13371383 if templ_7745c5c3_Err != nil {
13381384 return templ_7745c5c3_Err
13391385 }
1340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</time></td><td class=\"c-sync\">")
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</time></td><td class=\"c-sync\">")
13411387 if templ_7745c5c3_Err != nil {
13421388 return templ_7745c5c3_Err
13431389 }
13441390 if r.Synced {
1345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "yes")
1391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "yes")
13461392 if templ_7745c5c3_Err != nil {
13471393 return templ_7745c5c3_Err
13481394 }
13491395 } else {
1350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "no")
1396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "no")
13511397 if templ_7745c5c3_Err != nil {
13521398 return templ_7745c5c3_Err
13531399 }
13541400 }
1355- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
13561402 if templ_7745c5c3_Err != nil {
13571403 return templ_7745c5c3_Err
13581404 }
1359- var templ_7745c5c3_Var66 templ.SafeURL
1360- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1405+ var templ_7745c5c3_Var68 templ.SafeURL
1406+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
13611407 if templ_7745c5c3_Err != nil {
1362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 280, Col: 60}
1408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 288, Col: 60}
13631409 }
1364- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1410+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
13651411 if templ_7745c5c3_Err != nil {
13661412 return templ_7745c5c3_Err
13671413 }
1368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
13691415 if templ_7745c5c3_Err != nil {
13701416 return templ_7745c5c3_Err
13711417 }
1372- var templ_7745c5c3_Var67 string
1373- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1418+ var templ_7745c5c3_Var69 string
1419+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
13741420 if templ_7745c5c3_Err != nil {
1375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 89}
1421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 89}
13761422 }
1377- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
13781424 if templ_7745c5c3_Err != nil {
13791425 return templ_7745c5c3_Err
13801426 }
1381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">Remove</button></form></td></tr>")
1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Remove</button></form></td></tr>")
13821428 if templ_7745c5c3_Err != nil {
13831429 return templ_7745c5c3_Err
13841430 }
13851431 }
1386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</tbody></table>")
1432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</tbody></table>")
13871433 if templ_7745c5c3_Err != nil {
13881434 return templ_7745c5c3_Err
13891435 }
13901436 } else {
1391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"lede dim\">no passkeys yet.</p>")
1437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<p class=\"lede dim\">no passkeys yet.</p>")
13921438 if templ_7745c5c3_Err != nil {
13931439 return templ_7745c5c3_Err
13941440 }
13951441 }
1396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ")
1442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " ")
13971443 if templ_7745c5c3_Err != nil {
13981444 return templ_7745c5c3_Err
13991445 }
14001446 if p.Full {
1401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<p class=\"dim\">")
1447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">")
14021448 if templ_7745c5c3_Err != nil {
14031449 return templ_7745c5c3_Err
14041450 }
1405- var templ_7745c5c3_Var68 string
1406- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1451+ var templ_7745c5c3_Var70 string
1452+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
14071453 if templ_7745c5c3_Err != nil {
1408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 292, Col: 31}
1454+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 300, Col: 31}
14091455 }
1410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1456+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
14111457 if templ_7745c5c3_Err != nil {
14121458 return templ_7745c5c3_Err
14131459 }
1414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " passkeys are registered, the most there can be. Remove one to add another.</p>")
14151461 if templ_7745c5c3_Err != nil {
14161462 return templ_7745c5c3_Err
14171463 }
14181464 } else {
1419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<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=\"")
1465+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<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=\"")
14201466 if templ_7745c5c3_Err != nil {
14211467 return templ_7745c5c3_Err
14221468 }
1423- var templ_7745c5c3_Var69 string
1424- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1469+ var templ_7745c5c3_Var71 string
1470+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
14251471 if templ_7745c5c3_Err != nil {
1426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 90}
1472+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 90}
14271473 }
1428- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1474+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
14291475 if templ_7745c5c3_Err != nil {
14301476 return templ_7745c5c3_Err
14311477 }
1432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" data-finish=\"")
1478+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" data-finish=\"")
14331479 if templ_7745c5c3_Err != nil {
14341480 return templ_7745c5c3_Err
14351481 }
1436- var templ_7745c5c3_Var70 string
1437- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1482+ var templ_7745c5c3_Var72 string
1483+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
14381484 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 115}
1485+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 115}
14401486 }
1441- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
1487+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
14421488 if templ_7745c5c3_Err != nil {
14431489 return templ_7745c5c3_Err
14441490 }
1445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Add a passkey</button></form>")
1491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">Add a passkey</button></form>")
14461492 if templ_7745c5c3_Err != nil {
14471493 return templ_7745c5c3_Err
14481494 }
14491495 }
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
14511497 if templ_7745c5c3_Err != nil {
14521498 return templ_7745c5c3_Err
14531499 }
14541500 return nil
14551501 })
1456- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer)
1502+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var61), templ_7745c5c3_Buffer)
14571503 if templ_7745c5c3_Err != nil {
14581504 return templ_7745c5c3_Err
14591505 }
@@ -44,89 +44,135 @@ func Home(l Layout, p HomePage) templ.Component {
44 }()44 }()
45 }45 }
46 ctx = templ.InitializeContext(ctx)46 ctx = templ.InitializeContext(ctx)
47- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")47+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\">")
48 if templ_7745c5c3_Err != nil {48 if templ_7745c5c3_Err != nil {
49 return templ_7745c5c3_Err49 return templ_7745c5c3_Err
50 }50 }
51- for _, s := range p.Sorts {51+ if p.Inbox != nil {
52- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"")52+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"btn inbox-btn\" href=\"")
53 if templ_7745c5c3_Err != nil {53 if templ_7745c5c3_Err != nil {
54 return templ_7745c5c3_Err54 return templ_7745c5c3_Err
55 }55 }
56 var templ_7745c5c3_Var3 templ.SafeURL56 var templ_7745c5c3_Var3 templ.SafeURL
57- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))57+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Inbox.Href))
58 if templ_7745c5c3_Err != nil {58 if templ_7745c5c3_Err != nil {
59- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 27, Col: 36}59+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 13, Col: 65}
60 }60 }
61 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))61 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
62 if templ_7745c5c3_Err != nil {62 if templ_7745c5c3_Err != nil {
63 return templ_7745c5c3_Err63 return templ_7745c5c3_Err
64 }64 }
65- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")65+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" title=\"inbox\">Inbox ")
66+ if templ_7745c5c3_Err != nil {
67+ return templ_7745c5c3_Err
68+ }
69+ if p.Inbox.Count >= 0 {
70+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"count\">")
71+ if templ_7745c5c3_Err != nil {
72+ return templ_7745c5c3_Err
73+ }
74+ var templ_7745c5c3_Var4 string
75+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Inbox.Count))
76+ if templ_7745c5c3_Err != nil {
77+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 16, Col: 49}
78+ }
79+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
80+ if templ_7745c5c3_Err != nil {
81+ return templ_7745c5c3_Err
82+ }
83+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>")
84+ if templ_7745c5c3_Err != nil {
85+ return templ_7745c5c3_Err
86+ }
87+ }
88+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
89+ if templ_7745c5c3_Err != nil {
90+ return templ_7745c5c3_Err
91+ }
92+ }
93+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")
94+ if templ_7745c5c3_Err != nil {
95+ return templ_7745c5c3_Err
96+ }
97+ for _, s := range p.Sorts {
98+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
99+ if templ_7745c5c3_Err != nil {
100+ return templ_7745c5c3_Err
101+ }
102+ var templ_7745c5c3_Var5 templ.SafeURL
103+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
104+ if templ_7745c5c3_Err != nil {
105+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 36}
106+ }
107+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
108+ if templ_7745c5c3_Err != nil {
109+ return templ_7745c5c3_Err
110+ }
111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
66 if templ_7745c5c3_Err != nil {112 if templ_7745c5c3_Err != nil {
67 return templ_7745c5c3_Err113 return templ_7745c5c3_Err
68 }114 }
69 if s.Mark != "" {115 if s.Mark != "" {
70- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " aria-current=\"true\"")116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"true\"")
71 if templ_7745c5c3_Err != nil {117 if templ_7745c5c3_Err != nil {
72 return templ_7745c5c3_Err118 return templ_7745c5c3_Err
73 }119 }
74 }120 }
75- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">")121+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">")
76 if templ_7745c5c3_Err != nil {122 if templ_7745c5c3_Err != nil {
77 return templ_7745c5c3_Err123 return templ_7745c5c3_Err
78 }124 }
79- var templ_7745c5c3_Var4 string125+ var templ_7745c5c3_Var6 string
80- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)126+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
81 if templ_7745c5c3_Err != nil {127 if templ_7745c5c3_Err != nil {
82- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 32, Col: 17}128+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 40, Col: 17}
83 }129 }
84- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))130+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
85 if templ_7745c5c3_Err != nil {131 if templ_7745c5c3_Err != nil {
86 return templ_7745c5c3_Err132 return templ_7745c5c3_Err
87 }133 }
88- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
89 if templ_7745c5c3_Err != nil {135 if templ_7745c5c3_Err != nil {
90 return templ_7745c5c3_Err136 return templ_7745c5c3_Err
91 }137 }
92 if s.Mark != "" {138 if s.Mark != "" {
93- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span aria-hidden=\"true\">")139+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span aria-hidden=\"true\">")
94 if templ_7745c5c3_Err != nil {140 if templ_7745c5c3_Err != nil {
95 return templ_7745c5c3_Err141 return templ_7745c5c3_Err
96 }142 }
97- var templ_7745c5c3_Var5 string143+ var templ_7745c5c3_Var7 string
98- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark)144+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark)
99 if templ_7745c5c3_Err != nil {145 if templ_7745c5c3_Err != nil {
100- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 34, Col: 42}146+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 42, Col: 42}
101 }147 }
102- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))148+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
103 if templ_7745c5c3_Err != nil {149 if templ_7745c5c3_Err != nil {
104 return templ_7745c5c3_Err150 return templ_7745c5c3_Err
105 }151 }
106- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <span class=\"sr\">")152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span class=\"sr\">")
107 if templ_7745c5c3_Err != nil {153 if templ_7745c5c3_Err != nil {
108 return templ_7745c5c3_Err154 return templ_7745c5c3_Err
109 }155 }
110- var templ_7745c5c3_Var6 string156+ var templ_7745c5c3_Var8 string
111- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State)157+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State)
112 if templ_7745c5c3_Err != nil {158 if templ_7745c5c3_Err != nil {
113- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 42}159+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 43, Col: 42}
114 }160 }
115- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))161+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
116 if templ_7745c5c3_Err != nil {162 if templ_7745c5c3_Err != nil {
117 return templ_7745c5c3_Err163 return templ_7745c5c3_Err
118 }164 }
119- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")165+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>")
120 if templ_7745c5c3_Err != nil {166 if templ_7745c5c3_Err != nil {
121 return templ_7745c5c3_Err167 return templ_7745c5c3_Err
122 }168 }
123 }169 }
124- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</a>")170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a>")
125 if templ_7745c5c3_Err != nil {171 if templ_7745c5c3_Err != nil {
126 return templ_7745c5c3_Err172 return templ_7745c5c3_Err
127 }173 }
128 }174 }
129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")
130 if templ_7745c5c3_Err != nil {176 if templ_7745c5c3_Err != nil {
131 return templ_7745c5c3_Err177 return templ_7745c5c3_Err
132 }178 }
@@ -136,17 +182,17 @@ func Home(l Layout, p HomePage) templ.Component {
136 return templ_7745c5c3_Err182 return templ_7745c5c3_Err
137 }183 }
138 }184 }
139- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ul>")185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</ul>")
140 if templ_7745c5c3_Err != nil {186 if templ_7745c5c3_Err != nil {
141 return templ_7745c5c3_Err187 return templ_7745c5c3_Err
142 }188 }
143 if len(p.Rows) == 0 {189 if len(p.Rows) == 0 {
144- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"lede dim\">no repositories yet.</p>")190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no repositories yet.</p>")
145 if templ_7745c5c3_Err != nil {191 if templ_7745c5c3_Err != nil {
146 return templ_7745c5c3_Err192 return templ_7745c5c3_Err
147 }193 }
148 }194 }
149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</section></div>")195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section></div>")
150 if templ_7745c5c3_Err != nil {196 if templ_7745c5c3_Err != nil {
151 return templ_7745c5c3_Err197 return templ_7745c5c3_Err
152 }198 }
@@ -178,12 +224,12 @@ func Summary(l Layout, p SummaryPage) templ.Component {
178 }()224 }()
179 }225 }
180 ctx = templ.InitializeContext(ctx)226 ctx = templ.InitializeContext(ctx)
181- templ_7745c5c3_Var7 := templ.GetChildren(ctx)227+ templ_7745c5c3_Var9 := templ.GetChildren(ctx)
182- if templ_7745c5c3_Var7 == nil {228+ if templ_7745c5c3_Var9 == nil {
183- templ_7745c5c3_Var7 = templ.NopComponent229+ templ_7745c5c3_Var9 = templ.NopComponent
184 }230 }
185 ctx = templ.ClearChildren(ctx)231 ctx = templ.ClearChildren(ctx)
186- templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {232+ templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
187 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context233 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
188 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)234 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
189 if !templ_7745c5c3_IsBuffer {235 if !templ_7745c5c3_IsBuffer {
@@ -203,7 +249,7 @@ func Summary(l Layout, p SummaryPage) templ.Component {
203 }249 }
204 return nil250 return nil
205 })251 })
206- templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)252+ templ_7745c5c3_Err = Shell(l, summaryRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
207 if templ_7745c5c3_Err != nil {253 if templ_7745c5c3_Err != nil {
208 return templ_7745c5c3_Err254 return templ_7745c5c3_Err
209 }255 }
@@ -230,158 +276,158 @@ func latestTagFact(t LatestTag) templ.Component {
230 }()276 }()
231 }277 }
232 ctx = templ.InitializeContext(ctx)278 ctx = templ.InitializeContext(ctx)
233- templ_7745c5c3_Var9 := templ.GetChildren(ctx)279+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
234- if templ_7745c5c3_Var9 == nil {280+ if templ_7745c5c3_Var11 == nil {
235- templ_7745c5c3_Var9 = templ.NopComponent281+ templ_7745c5c3_Var11 = templ.NopComponent
236 }282 }
237 ctx = templ.ClearChildren(ctx)283 ctx = templ.ClearChildren(ctx)
238- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<dt>latest tag</dt><dd class=\"latest-tag\">")284+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>latest tag</dt><dd class=\"latest-tag\">")
239 if templ_7745c5c3_Err != nil {285 if templ_7745c5c3_Err != nil {
240 return templ_7745c5c3_Err286 return templ_7745c5c3_Err
241 }287 }
242 if t.Name != "" {288 if t.Name != "" {
243- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a class=\"tag-name\" href=\"")289+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"tag-name\" href=\"")
244 if templ_7745c5c3_Err != nil {290 if templ_7745c5c3_Err != nil {
245 return templ_7745c5c3_Err291 return templ_7745c5c3_Err
246 }292 }
247- var templ_7745c5c3_Var10 templ.SafeURL293+ var templ_7745c5c3_Var12 templ.SafeURL
248- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))294+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))
249 if templ_7745c5c3_Err != nil {295 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 51}296+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 51}
251 }297 }
252- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))298+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
253 if templ_7745c5c3_Err != nil {299 if templ_7745c5c3_Err != nil {
254 return templ_7745c5c3_Err300 return templ_7745c5c3_Err
255 }301 }
256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" title=\"")302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" title=\"")
257 if templ_7745c5c3_Err != nil {303 if templ_7745c5c3_Err != nil {
258 return templ_7745c5c3_Err304 return templ_7745c5c3_Err
259 }305 }
260- var templ_7745c5c3_Var11 string306+ var templ_7745c5c3_Var13 string
261- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)307+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)
262 if templ_7745c5c3_Err != nil {308 if templ_7745c5c3_Err != nil {
263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 71}309+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 71}
264 }310 }
265- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)311+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
266 if templ_7745c5c3_Err != nil {312 if templ_7745c5c3_Err != nil {
267 return templ_7745c5c3_Err313 return templ_7745c5c3_Err
268 }314 }
269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")315+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
270 if templ_7745c5c3_Err != nil {316 if templ_7745c5c3_Err != nil {
271 return templ_7745c5c3_Err317 return templ_7745c5c3_Err
272 }318 }
273- var templ_7745c5c3_Var12 string319+ var templ_7745c5c3_Var14 string
274- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)320+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
275 if templ_7745c5c3_Err != nil {321 if templ_7745c5c3_Err != nil {
276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 82}322+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 82}
277 }323 }
278- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))324+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
279 if templ_7745c5c3_Err != nil {325 if templ_7745c5c3_Err != nil {
280 return templ_7745c5c3_Err326 return templ_7745c5c3_Err
281 }327 }
282- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> ")328+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</a> ")
283 if templ_7745c5c3_Err != nil {329 if templ_7745c5c3_Err != nil {
284 return templ_7745c5c3_Err330 return templ_7745c5c3_Err
285 }331 }
286 if t.WhenAt != "" {332 if t.WhenAt != "" {
287- var templ_7745c5c3_Var13 string333+ var templ_7745c5c3_Var15 string
288- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")334+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
289 if templ_7745c5c3_Err != nil {335 if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 12}336+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 12}
291 }337 }
292- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))338+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
293 if templ_7745c5c3_Err != nil {339 if templ_7745c5c3_Err != nil {
294 return templ_7745c5c3_Err340 return templ_7745c5c3_Err
295 }341 }
296- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<time datetime=\"")342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<time datetime=\"")
297 if templ_7745c5c3_Err != nil {343 if templ_7745c5c3_Err != nil {
298 return templ_7745c5c3_Err344 return templ_7745c5c3_Err
299 }345 }
300- var templ_7745c5c3_Var14 string346+ var templ_7745c5c3_Var16 string
301- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)347+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
302 if templ_7745c5c3_Err != nil {348 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 39}349+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 39}
304 }350 }
305- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)351+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
306 if templ_7745c5c3_Err != nil {352 if templ_7745c5c3_Err != nil {
307 return templ_7745c5c3_Err353 return templ_7745c5c3_Err
308 }354 }
309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" title=\"")355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"")
310 if templ_7745c5c3_Err != nil {356 if templ_7745c5c3_Err != nil {
311 return templ_7745c5c3_Err357 return templ_7745c5c3_Err
312 }358 }
313- var templ_7745c5c3_Var15 string359+ var templ_7745c5c3_Var17 string
314- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)360+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
315 if templ_7745c5c3_Err != nil {361 if templ_7745c5c3_Err != nil {
316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 58}362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 58}
317 }363 }
318- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
319 if templ_7745c5c3_Err != nil {365 if templ_7745c5c3_Err != nil {
320 return templ_7745c5c3_Err366 return templ_7745c5c3_Err
321 }367 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
323 if templ_7745c5c3_Err != nil {369 if templ_7745c5c3_Err != nil {
324 return templ_7745c5c3_Err370 return templ_7745c5c3_Err
325 }371 }
326- var templ_7745c5c3_Var16 string372+ var templ_7745c5c3_Var18 string
327- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)373+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)
328 if templ_7745c5c3_Err != nil {374 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 69}375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 69}
330 }376 }
331- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))377+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
332 if templ_7745c5c3_Err != nil {378 if templ_7745c5c3_Err != nil {
333 return templ_7745c5c3_Err379 return templ_7745c5c3_Err
334 }380 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</time>")381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time>")
336 if templ_7745c5c3_Err != nil {382 if templ_7745c5c3_Err != nil {
337 return templ_7745c5c3_Err383 return templ_7745c5c3_Err
338 }384 }
339 }385 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ")386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ")
341 if templ_7745c5c3_Err != nil {387 if templ_7745c5c3_Err != nil {
342 return templ_7745c5c3_Err388 return templ_7745c5c3_Err
343 }389 }
344- var templ_7745c5c3_Var17 string390+ var templ_7745c5c3_Var19 string
345- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")391+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
346 if templ_7745c5c3_Err != nil {392 if templ_7745c5c3_Err != nil {
347- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 11}393+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 94, Col: 11}
348 }394 }
349- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))395+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
350 if templ_7745c5c3_Err != nil {396 if templ_7745c5c3_Err != nil {
351 return templ_7745c5c3_Err397 return templ_7745c5c3_Err
352 }398 }
353- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ")399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ")
354 if templ_7745c5c3_Err != nil {400 if templ_7745c5c3_Err != nil {
355 return templ_7745c5c3_Err401 return templ_7745c5c3_Err
356 }402 }
357 }403 }
358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"all-tags\" href=\"")404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"all-tags\" href=\"")
359 if templ_7745c5c3_Err != nil {405 if templ_7745c5c3_Err != nil {
360 return templ_7745c5c3_Err406 return templ_7745c5c3_Err
361 }407 }
362- var templ_7745c5c3_Var18 templ.SafeURL408+ var templ_7745c5c3_Var20 templ.SafeURL
363- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))409+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))
364 if templ_7745c5c3_Err != nil {410 if templ_7745c5c3_Err != nil {
365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 54}411+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 54}
366 }412 }
367- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))413+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
368 if templ_7745c5c3_Err != nil {414 if templ_7745c5c3_Err != nil {
369 return templ_7745c5c3_Err415 return templ_7745c5c3_Err
370 }416 }
371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
372 if templ_7745c5c3_Err != nil {418 if templ_7745c5c3_Err != nil {
373 return templ_7745c5c3_Err419 return templ_7745c5c3_Err
374 }420 }
375- var templ_7745c5c3_Var19 string421+ var templ_7745c5c3_Var21 string
376- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))422+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))
377 if templ_7745c5c3_Err != nil {423 if templ_7745c5c3_Err != nil {
378- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 89}424+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 89}
379 }425 }
380- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))426+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
381 if templ_7745c5c3_Err != nil {427 if templ_7745c5c3_Err != nil {
382 return templ_7745c5c3_Err428 return templ_7745c5c3_Err
383 }429 }
384- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a></dd>")430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a></dd>")
385 if templ_7745c5c3_Err != nil {431 if templ_7745c5c3_Err != nil {
386 return templ_7745c5c3_Err432 return templ_7745c5c3_Err
387 }433 }
@@ -405,12 +451,12 @@ func summaryRail(p SummaryPage) templ.Component {
405 }()451 }()
406 }452 }
407 ctx = templ.InitializeContext(ctx)453 ctx = templ.InitializeContext(ctx)
408- templ_7745c5c3_Var20 := templ.GetChildren(ctx)454+ templ_7745c5c3_Var22 := templ.GetChildren(ctx)
409- if templ_7745c5c3_Var20 == nil {455+ if templ_7745c5c3_Var22 == nil {
410- templ_7745c5c3_Var20 = templ.NopComponent456+ templ_7745c5c3_Var22 = templ.NopComponent
411 }457 }
412 ctx = templ.ClearChildren(ctx)458 ctx = templ.ClearChildren(ctx)
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>")459+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>")
414 if templ_7745c5c3_Err != nil {460 if templ_7745c5c3_Err != nil {
415 return templ_7745c5c3_Err461 return templ_7745c5c3_Err
416 }462 }
@@ -418,25 +464,25 @@ func summaryRail(p SummaryPage) templ.Component {
418 if templ_7745c5c3_Err != nil {464 if templ_7745c5c3_Err != nil {
419 return templ_7745c5c3_Err465 return templ_7745c5c3_Err
420 }466 }
421- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">")467+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">")
422 if templ_7745c5c3_Err != nil {468 if templ_7745c5c3_Err != nil {
423 return templ_7745c5c3_Err469 return templ_7745c5c3_Err
424 }470 }
425 for _, f := range p.Facts {471 for _, f := range p.Facts {
426- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<dt>")472+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<dt>")
427 if templ_7745c5c3_Err != nil {473 if templ_7745c5c3_Err != nil {
428 return templ_7745c5c3_Err474 return templ_7745c5c3_Err
429 }475 }
430- var templ_7745c5c3_Var21 string476+ var templ_7745c5c3_Var23 string
431- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)477+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)
432 if templ_7745c5c3_Err != nil {478 if templ_7745c5c3_Err != nil {
433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 99, Col: 15}479+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 107, Col: 15}
434 }480 }
435- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))481+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
436 if templ_7745c5c3_Err != nil {482 if templ_7745c5c3_Err != nil {
437 return templ_7745c5c3_Err483 return templ_7745c5c3_Err
438 }484 }
439- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</dt>")485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</dt>")
440 if templ_7745c5c3_Err != nil {486 if templ_7745c5c3_Err != nil {
441 return templ_7745c5c3_Err487 return templ_7745c5c3_Err
442 }488 }
@@ -451,25 +497,25 @@ func summaryRail(p SummaryPage) templ.Component {
451 return templ_7745c5c3_Err497 return templ_7745c5c3_Err
452 }498 }
453 }499 }
454- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</dl></section>")500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</dl></section>")
455 if templ_7745c5c3_Err != nil {501 if templ_7745c5c3_Err != nil {
456 return templ_7745c5c3_Err502 return templ_7745c5c3_Err
457 }503 }
458 if len(p.Recent) > 0 {504 if len(p.Recent) > 0 {
459- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"")
460 if templ_7745c5c3_Err != nil {506 if templ_7745c5c3_Err != nil {
461 return templ_7745c5c3_Err507 return templ_7745c5c3_Err
462 }508 }
463- var templ_7745c5c3_Var22 templ.SafeURL509+ var templ_7745c5c3_Var24 templ.SafeURL
464- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))510+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
465 if templ_7745c5c3_Err != nil {511 if templ_7745c5c3_Err != nil {
466- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 108, Col: 105}512+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 116, Col: 105}
467 }513 }
468- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))514+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
469 if templ_7745c5c3_Err != nil {515 if templ_7745c5c3_Err != nil {
470 return templ_7745c5c3_Err516 return templ_7745c5c3_Err
471 }517 }
472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">View all</a></h2>")518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">View all</a></h2>")
473 if templ_7745c5c3_Err != nil {519 if templ_7745c5c3_Err != nil {
474 return templ_7745c5c3_Err520 return templ_7745c5c3_Err
475 }521 }
@@ -477,7 +523,7 @@ func summaryRail(p SummaryPage) templ.Component {
477 if templ_7745c5c3_Err != nil {523 if templ_7745c5c3_Err != nil {
478 return templ_7745c5c3_Err524 return templ_7745c5c3_Err
479 }525 }
480- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>")526+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</section>")
481 if templ_7745c5c3_Err != nil {527 if templ_7745c5c3_Err != nil {
482 return templ_7745c5c3_Err528 return templ_7745c5c3_Err
483 }529 }
@@ -489,7 +535,7 @@ func summaryRail(p SummaryPage) templ.Component {
489 }535 }
490 }536 }
491 if len(p.Outline) > 0 && p.Readme != nil {537 if len(p.Outline) > 0 && p.Readme != nil {
492- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
493 if templ_7745c5c3_Err != nil {539 if templ_7745c5c3_Err != nil {
494 return templ_7745c5c3_Err540 return templ_7745c5c3_Err
495 }541 }
@@ -497,7 +543,7 @@ func summaryRail(p SummaryPage) templ.Component {
497 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
498 return templ_7745c5c3_Err544 return templ_7745c5c3_Err
499 }545 }
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section>")546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>")
501 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
502 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
503 }549 }
@@ -523,12 +569,12 @@ func Refs(l Layout, p RefsPage) templ.Component {
523 }()569 }()
524 }570 }
525 ctx = templ.InitializeContext(ctx)571 ctx = templ.InitializeContext(ctx)
526- templ_7745c5c3_Var23 := templ.GetChildren(ctx)572+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
527- if templ_7745c5c3_Var23 == nil {573+ if templ_7745c5c3_Var25 == nil {
528- templ_7745c5c3_Var23 = templ.NopComponent574+ templ_7745c5c3_Var25 = templ.NopComponent
529 }575 }
530 ctx = templ.ClearChildren(ctx)576 ctx = templ.ClearChildren(ctx)
531- templ_7745c5c3_Var24 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {577+ templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
532 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context578 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
533 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)579 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
534 if !templ_7745c5c3_IsBuffer {580 if !templ_7745c5c3_IsBuffer {
@@ -540,7 +586,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
540 }()586 }()
541 }587 }
542 ctx = templ.InitializeContext(ctx)588 ctx = templ.InitializeContext(ctx)
543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<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>")589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<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>")
544 if templ_7745c5c3_Err != nil {590 if templ_7745c5c3_Err != nil {
545 return templ_7745c5c3_Err591 return templ_7745c5c3_Err
546 }592 }
@@ -548,32 +594,32 @@ func Refs(l Layout, p RefsPage) templ.Component {
548 if templ_7745c5c3_Err != nil {594 if templ_7745c5c3_Err != nil {
549 return templ_7745c5c3_Err595 return templ_7745c5c3_Err
550 }596 }
551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</table>")597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</table>")
552 if templ_7745c5c3_Err != nil {598 if templ_7745c5c3_Err != nil {
553 return templ_7745c5c3_Err599 return templ_7745c5c3_Err
554 }600 }
555 if p.Note != "" {601 if p.Note != "" {
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"note\">")602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<p class=\"note\">")
557 if templ_7745c5c3_Err != nil {603 if templ_7745c5c3_Err != nil {
558 return templ_7745c5c3_Err604 return templ_7745c5c3_Err
559 }605 }
560- var templ_7745c5c3_Var25 string606+ var templ_7745c5c3_Var27 string
561- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)607+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
562 if templ_7745c5c3_Err != nil {608 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 141, Col: 27}609+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 149, Col: 27}
564 }610 }
565- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))611+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
566 if templ_7745c5c3_Err != nil {612 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err613 return templ_7745c5c3_Err
568 }614 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p>")
570 if templ_7745c5c3_Err != nil {616 if templ_7745c5c3_Err != nil {
571 return templ_7745c5c3_Err617 return templ_7745c5c3_Err
572 }618 }
573 }619 }
574 return nil620 return nil
575 })621 })
576- templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var24), templ_7745c5c3_Buffer)622+ templ_7745c5c3_Err = Shell(l, refsRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer)
577 if templ_7745c5c3_Err != nil {623 if templ_7745c5c3_Err != nil {
578 return templ_7745c5c3_Err624 return templ_7745c5c3_Err
579 }625 }
@@ -597,112 +643,112 @@ func refRow(kind string, r RefRow) templ.Component {
597 }()643 }()
598 }644 }
599 ctx = templ.InitializeContext(ctx)645 ctx = templ.InitializeContext(ctx)
600- templ_7745c5c3_Var26 := templ.GetChildren(ctx)646+ templ_7745c5c3_Var28 := templ.GetChildren(ctx)
601- if templ_7745c5c3_Var26 == nil {647+ if templ_7745c5c3_Var28 == nil {
602- templ_7745c5c3_Var26 = templ.NopComponent648+ templ_7745c5c3_Var28 = templ.NopComponent
603 }649 }
604 ctx = templ.ClearChildren(ctx)650 ctx = templ.ClearChildren(ctx)
605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<tr")651+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<tr")
606 if templ_7745c5c3_Err != nil {652 if templ_7745c5c3_Err != nil {
607 return templ_7745c5c3_Err653 return templ_7745c5c3_Err
608 }654 }
609 if r.Cursor {655 if r.Cursor {
610- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " data-cursor")656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-cursor")
611 if templ_7745c5c3_Err != nil {657 if templ_7745c5c3_Err != nil {
612 return templ_7745c5c3_Err658 return templ_7745c5c3_Err
613 }659 }
614 }660 }
615- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"c-kind mute\">")661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><td class=\"c-kind mute\">")
616 if templ_7745c5c3_Err != nil {662 if templ_7745c5c3_Err != nil {
617 return templ_7745c5c3_Err663 return templ_7745c5c3_Err
618 }664 }
619- var templ_7745c5c3_Var27 string665+ var templ_7745c5c3_Var29 string
620- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)666+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
621 if templ_7745c5c3_Err != nil {667 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 32}668+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 32}
623 }669 }
624- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))670+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
625 if templ_7745c5c3_Err != nil {671 if templ_7745c5c3_Err != nil {
626 return templ_7745c5c3_Err672 return templ_7745c5c3_Err
627 }673 }
628- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</td><td class=\"c-name\"><a href=\"")674+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-name\"><a href=\"")
629 if templ_7745c5c3_Err != nil {675 if templ_7745c5c3_Err != nil {
630 return templ_7745c5c3_Err676 return templ_7745c5c3_Err
631 }677 }
632- var templ_7745c5c3_Var28 templ.SafeURL678+ var templ_7745c5c3_Var30 templ.SafeURL
633- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))679+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
634 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 34}681+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 34}
636 }682 }
637- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))683+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
638 if templ_7745c5c3_Err != nil {684 if templ_7745c5c3_Err != nil {
639 return templ_7745c5c3_Err685 return templ_7745c5c3_Err
640 }686 }
641- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")687+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
642 if templ_7745c5c3_Err != nil {688 if templ_7745c5c3_Err != nil {
643 return templ_7745c5c3_Err689 return templ_7745c5c3_Err
644 }690 }
645- var templ_7745c5c3_Var29 string691+ var templ_7745c5c3_Var31 string
646- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)692+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
647 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 45}694+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 45}
649 }695 }
650- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))696+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
651 if templ_7745c5c3_Err != nil {697 if templ_7745c5c3_Err != nil {
652 return templ_7745c5c3_Err698 return templ_7745c5c3_Err
653 }699 }
654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a> ")700+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> ")
655 if templ_7745c5c3_Err != nil {701 if templ_7745c5c3_Err != nil {
656 return templ_7745c5c3_Err702 return templ_7745c5c3_Err
657 }703 }
658 if r.Default {704 if r.Default {
659- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"pill\">default</span>")705+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"pill\">default</span>")
660 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
661 return templ_7745c5c3_Err707 return templ_7745c5c3_Err
662 }708 }
663 }709 }
664- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-sha\">")710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-sha\">")
665 if templ_7745c5c3_Err != nil {711 if templ_7745c5c3_Err != nil {
666 return templ_7745c5c3_Err712 return templ_7745c5c3_Err
667 }713 }
668 if r.CHref != "" {714 if r.CHref != "" {
669- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<a href=\"")715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"")
670 if templ_7745c5c3_Err != nil {716 if templ_7745c5c3_Err != nil {
671 return templ_7745c5c3_Err717 return templ_7745c5c3_Err
672 }718 }
673- var templ_7745c5c3_Var30 templ.SafeURL719+ var templ_7745c5c3_Var32 templ.SafeURL
674- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))720+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
675 if templ_7745c5c3_Err != nil {721 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 36}722+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 36}
677 }723 }
678- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))724+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
679 if templ_7745c5c3_Err != nil {725 if templ_7745c5c3_Err != nil {
680 return templ_7745c5c3_Err726 return templ_7745c5c3_Err
681 }727 }
682- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">")728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")
683 if templ_7745c5c3_Err != nil {729 if templ_7745c5c3_Err != nil {
684 return templ_7745c5c3_Err730 return templ_7745c5c3_Err
685 }731 }
686- var templ_7745c5c3_Var31 string732+ var templ_7745c5c3_Var33 string
687- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)733+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
688 if templ_7745c5c3_Err != nil {734 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 48}735+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 48}
690 }736 }
691- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))737+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
692 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
693 return templ_7745c5c3_Err739 return templ_7745c5c3_Err
694 }740 }
695- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</a>")
696 if templ_7745c5c3_Err != nil {742 if templ_7745c5c3_Err != nil {
697 return templ_7745c5c3_Err743 return templ_7745c5c3_Err
698 }744 }
699 } else {745 } else {
700- var templ_7745c5c3_Var32 string746+ var templ_7745c5c3_Var34 string
701- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)747+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
702 if templ_7745c5c3_Err != nil {748 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 13}749+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 167, Col: 13}
704 }750 }
705- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))751+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
706 if templ_7745c5c3_Err != nil {752 if templ_7745c5c3_Err != nil {
707 return templ_7745c5c3_Err753 return templ_7745c5c3_Err
708 }754 }
@@ -711,69 +757,69 @@ func refRow(kind string, r RefRow) templ.Component {
711 if templ_7745c5c3_Err != nil {757 if templ_7745c5c3_Err != nil {
712 return templ_7745c5c3_Err758 return templ_7745c5c3_Err
713 }759 }
714- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"c-sub\">")760+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td><td class=\"c-sub\">")
715 if templ_7745c5c3_Err != nil {761 if templ_7745c5c3_Err != nil {
716 return templ_7745c5c3_Err762 return templ_7745c5c3_Err
717 }763 }
718- var templ_7745c5c3_Var33 string764+ var templ_7745c5c3_Var35 string
719- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)765+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
720 if templ_7745c5c3_Err != nil {766 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 31}767+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 31}
722 }768 }
723- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))769+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
724 if templ_7745c5c3_Err != nil {770 if templ_7745c5c3_Err != nil {
725 return templ_7745c5c3_Err771 return templ_7745c5c3_Err
726 }772 }
727- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-when dim\">")773+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"c-when dim\">")
728 if templ_7745c5c3_Err != nil {774 if templ_7745c5c3_Err != nil {
729 return templ_7745c5c3_Err775 return templ_7745c5c3_Err
730 }776 }
731 if r.WhenAt != "" {777 if r.WhenAt != "" {
732- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<time datetime=\"")778+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<time datetime=\"")
733 if templ_7745c5c3_Err != nil {779 if templ_7745c5c3_Err != nil {
734 return templ_7745c5c3_Err780 return templ_7745c5c3_Err
735 }781 }
736- var templ_7745c5c3_Var34 string782+ var templ_7745c5c3_Var36 string
737- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)783+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
738 if templ_7745c5c3_Err != nil {784 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 29}785+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 29}
740 }786 }
741- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)787+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
742 if templ_7745c5c3_Err != nil {788 if templ_7745c5c3_Err != nil {
743 return templ_7745c5c3_Err789 return templ_7745c5c3_Err
744 }790 }
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" title=\"")791+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"")
746 if templ_7745c5c3_Err != nil {792 if templ_7745c5c3_Err != nil {
747 return templ_7745c5c3_Err793 return templ_7745c5c3_Err
748 }794 }
749- var templ_7745c5c3_Var35 string795+ var templ_7745c5c3_Var37 string
750- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)796+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
751 if templ_7745c5c3_Err != nil {797 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48}798+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 48}
753 }799 }
754- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)800+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
755 if templ_7745c5c3_Err != nil {801 if templ_7745c5c3_Err != nil {
756 return templ_7745c5c3_Err802 return templ_7745c5c3_Err
757 }803 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")804+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
759 if templ_7745c5c3_Err != nil {805 if templ_7745c5c3_Err != nil {
760 return templ_7745c5c3_Err806 return templ_7745c5c3_Err
761 }807 }
762- var templ_7745c5c3_Var36 string808+ var templ_7745c5c3_Var38 string
763- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)809+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
764 if templ_7745c5c3_Err != nil {810 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 59}811+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 59}
766 }812 }
767- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))813+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
768 if templ_7745c5c3_Err != nil {814 if templ_7745c5c3_Err != nil {
769 return templ_7745c5c3_Err815 return templ_7745c5c3_Err
770 }816 }
771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</time>")817+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</time>")
772 if templ_7745c5c3_Err != nil {818 if templ_7745c5c3_Err != nil {
773 return templ_7745c5c3_Err819 return templ_7745c5c3_Err
774 }820 }
775 }821 }
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td></tr>")822+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td></tr>")
777 if templ_7745c5c3_Err != nil {823 if templ_7745c5c3_Err != nil {
778 return templ_7745c5c3_Err824 return templ_7745c5c3_Err
779 }825 }
@@ -797,12 +843,12 @@ func refsRail(p RefsPage) templ.Component {
797 }()843 }()
798 }844 }
799 ctx = templ.InitializeContext(ctx)845 ctx = templ.InitializeContext(ctx)
800- templ_7745c5c3_Var37 := templ.GetChildren(ctx)846+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
801- if templ_7745c5c3_Var37 == nil {847+ if templ_7745c5c3_Var39 == nil {
802- templ_7745c5c3_Var37 = templ.NopComponent848+ templ_7745c5c3_Var39 = templ.NopComponent
803 }849 }
804 ctx = templ.ClearChildren(ctx)850 ctx = templ.ClearChildren(ctx)
805- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")851+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>")
806 if templ_7745c5c3_Err != nil {852 if templ_7745c5c3_Err != nil {
807 return templ_7745c5c3_Err853 return templ_7745c5c3_Err
808 }854 }
@@ -810,7 +856,7 @@ func refsRail(p RefsPage) templ.Component {
810 if templ_7745c5c3_Err != nil {856 if templ_7745c5c3_Err != nil {
811 return templ_7745c5c3_Err857 return templ_7745c5c3_Err
812 }858 }
813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</section>")859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</section>")
814 if templ_7745c5c3_Err != nil {860 if templ_7745c5c3_Err != nil {
815 return templ_7745c5c3_Err861 return templ_7745c5c3_Err
816 }862 }
@@ -835,12 +881,12 @@ func State(l Layout, p StatePage) templ.Component {
835 }()881 }()
836 }882 }
837 ctx = templ.InitializeContext(ctx)883 ctx = templ.InitializeContext(ctx)
838- templ_7745c5c3_Var38 := templ.GetChildren(ctx)884+ templ_7745c5c3_Var40 := templ.GetChildren(ctx)
839- if templ_7745c5c3_Var38 == nil {885+ if templ_7745c5c3_Var40 == nil {
840- templ_7745c5c3_Var38 = templ.NopComponent886+ templ_7745c5c3_Var40 = templ.NopComponent
841 }887 }
842 ctx = templ.ClearChildren(ctx)888 ctx = templ.ClearChildren(ctx)
843- templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {889+ templ_7745c5c3_Var41 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
844 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context890 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
845 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)891 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
846 if !templ_7745c5c3_IsBuffer {892 if !templ_7745c5c3_IsBuffer {
@@ -852,51 +898,51 @@ func State(l Layout, p StatePage) templ.Component {
852 }()898 }()
853 }899 }
854 ctx = templ.InitializeContext(ctx)900 ctx = templ.InitializeContext(ctx)
855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")901+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">")
856 if templ_7745c5c3_Err != nil {902 if templ_7745c5c3_Err != nil {
857 return templ_7745c5c3_Err903 return templ_7745c5c3_Err
858 }904 }
859- var templ_7745c5c3_Var40 string905+ var templ_7745c5c3_Var42 string
860- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)906+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
861 if templ_7745c5c3_Err != nil {907 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 30}908+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 30}
863 }909 }
864- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))910+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
865 if templ_7745c5c3_Err != nil {911 if templ_7745c5c3_Err != nil {
866 return templ_7745c5c3_Err912 return templ_7745c5c3_Err
867 }913 }
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</p><h1 id=\"state-title\">")914+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</p><h1 id=\"state-title\">")
869 if templ_7745c5c3_Err != nil {915 if templ_7745c5c3_Err != nil {
870 return templ_7745c5c3_Err916 return templ_7745c5c3_Err
871 }917 }
872- var templ_7745c5c3_Var41 string918+ var templ_7745c5c3_Var43 string
873- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)919+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
874 if templ_7745c5c3_Err != nil {920 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 33}921+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 192, Col: 33}
876 }922 }
877- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))923+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
878 if templ_7745c5c3_Err != nil {924 if templ_7745c5c3_Err != nil {
879 return templ_7745c5c3_Err925 return templ_7745c5c3_Err
880 }926 }
881- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</h1>")927+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</h1>")
882 if templ_7745c5c3_Err != nil {928 if templ_7745c5c3_Err != nil {
883 return templ_7745c5c3_Err929 return templ_7745c5c3_Err
884 }930 }
885 for _, line := range p.Lines {931 for _, line := range p.Lines {
886- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<p class=\"dim\">")932+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"dim\">")
887 if templ_7745c5c3_Err != nil {933 if templ_7745c5c3_Err != nil {
888 return templ_7745c5c3_Err934 return templ_7745c5c3_Err
889 }935 }
890- var templ_7745c5c3_Var42 string936+ var templ_7745c5c3_Var44 string
891- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)937+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(line)
892 if templ_7745c5c3_Err != nil {938 if templ_7745c5c3_Err != nil {
893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 186, Col: 25}939+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 25}
894 }940 }
895- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))941+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
896 if templ_7745c5c3_Err != nil {942 if templ_7745c5c3_Err != nil {
897 return templ_7745c5c3_Err943 return templ_7745c5c3_Err
898 }944 }
899- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</p>")945+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p>")
900 if templ_7745c5c3_Err != nil {946 if templ_7745c5c3_Err != nil {
901 return templ_7745c5c3_Err947 return templ_7745c5c3_Err
902 }948 }
@@ -907,49 +953,49 @@ func State(l Layout, p StatePage) templ.Component {
907 return templ_7745c5c3_Err953 return templ_7745c5c3_Err
908 }954 }
909 }955 }
910- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"state-back\">")956+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<p class=\"state-back\">")
911 if templ_7745c5c3_Err != nil {957 if templ_7745c5c3_Err != nil {
912 return templ_7745c5c3_Err958 return templ_7745c5c3_Err
913 }959 }
914 for _, b := range p.Back {960 for _, b := range p.Back {
915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<a href=\"")961+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<a href=\"")
916 if templ_7745c5c3_Err != nil {962 if templ_7745c5c3_Err != nil {
917 return templ_7745c5c3_Err963 return templ_7745c5c3_Err
918 }964 }
919- var templ_7745c5c3_Var43 templ.SafeURL965+ var templ_7745c5c3_Var45 templ.SafeURL
920- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))966+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
921 if templ_7745c5c3_Err != nil {967 if templ_7745c5c3_Err != nil {
922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 36}968+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 36}
923 }969 }
924- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))970+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
925 if templ_7745c5c3_Err != nil {971 if templ_7745c5c3_Err != nil {
926 return templ_7745c5c3_Err972 return templ_7745c5c3_Err
927 }973 }
928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">")974+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\">")
929 if templ_7745c5c3_Err != nil {975 if templ_7745c5c3_Err != nil {
930 return templ_7745c5c3_Err976 return templ_7745c5c3_Err
931 }977 }
932- var templ_7745c5c3_Var44 string978+ var templ_7745c5c3_Var46 string
933- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)979+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
934 if templ_7745c5c3_Err != nil {980 if templ_7745c5c3_Err != nil {
935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 48}981+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 48}
936 }982 }
937- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))983+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
938 if templ_7745c5c3_Err != nil {984 if templ_7745c5c3_Err != nil {
939 return templ_7745c5c3_Err985 return templ_7745c5c3_Err
940 }986 }
941- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</a>")987+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</a>")
942 if templ_7745c5c3_Err != nil {988 if templ_7745c5c3_Err != nil {
943 return templ_7745c5c3_Err989 return templ_7745c5c3_Err
944 }990 }
945 }991 }
946- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</p></section>")992+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</p></section>")
947 if templ_7745c5c3_Err != nil {993 if templ_7745c5c3_Err != nil {
948 return templ_7745c5c3_Err994 return templ_7745c5c3_Err
949 }995 }
950 return nil996 return nil
951 })997 })
952- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer)998+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var41), templ_7745c5c3_Buffer)
953 if templ_7745c5c3_Err != nil {999 if templ_7745c5c3_Err != nil {
954 return templ_7745c5c3_Err1000 return templ_7745c5c3_Err
955 }1001 }
@@ -978,12 +1024,12 @@ func Login(l Layout, p LoginPage) templ.Component {
978 }()1024 }()
979 }1025 }
980 ctx = templ.InitializeContext(ctx)1026 ctx = templ.InitializeContext(ctx)
981- templ_7745c5c3_Var45 := templ.GetChildren(ctx)1027+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
982- if templ_7745c5c3_Var45 == nil {1028+ if templ_7745c5c3_Var47 == nil {
983- templ_7745c5c3_Var45 = templ.NopComponent1029+ templ_7745c5c3_Var47 = templ.NopComponent
984 }1030 }
985 ctx = templ.ClearChildren(ctx)1031 ctx = templ.ClearChildren(ctx)
986- templ_7745c5c3_Var46 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1032+ templ_7745c5c3_Var48 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
987 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1033 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
988 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1034 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
989 if !templ_7745c5c3_IsBuffer {1035 if !templ_7745c5c3_IsBuffer {
@@ -995,75 +1041,75 @@ func Login(l Layout, p LoginPage) templ.Component {
995 }()1041 }()
996 }1042 }
997 ctx = templ.InitializeContext(ctx)1043 ctx = templ.InitializeContext(ctx)
998- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")1044+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
999 if templ_7745c5c3_Err != nil {1045 if templ_7745c5c3_Err != nil {
1000 return templ_7745c5c3_Err1046 return templ_7745c5c3_Err
1001 }1047 }
1002- var templ_7745c5c3_Var47 string1048+ var templ_7745c5c3_Var49 string
1003- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1049+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1004 if templ_7745c5c3_Err != nil {1050 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 33}1051+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 33}
1006 }1052 }
1007- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))1053+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1008 if templ_7745c5c3_Err != nil {1054 if templ_7745c5c3_Err != nil {
1009 return templ_7745c5c3_Err1055 return templ_7745c5c3_Err
1010 }1056 }
1011- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</h1>")1057+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</h1>")
1012 if templ_7745c5c3_Err != nil {1058 if templ_7745c5c3_Err != nil {
1013 return templ_7745c5c3_Err1059 return templ_7745c5c3_Err
1014 }1060 }
1015 for _, line := range p.Lines {1061 for _, line := range p.Lines {
1016- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"dim\">")1062+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"dim\">")
1017 if templ_7745c5c3_Err != nil {1063 if templ_7745c5c3_Err != nil {
1018 return templ_7745c5c3_Err1064 return templ_7745c5c3_Err
1019 }1065 }
1020- var templ_7745c5c3_Var48 string1066+ var templ_7745c5c3_Var50 string
1021- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)1067+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1022 if templ_7745c5c3_Err != nil {1068 if templ_7745c5c3_Err != nil {
1023- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 210, Col: 25}1069+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 25}
1024 }1070 }
1025- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))1071+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1026 if templ_7745c5c3_Err != nil {1072 if templ_7745c5c3_Err != nil {
1027 return templ_7745c5c3_Err1073 return templ_7745c5c3_Err
1028 }1074 }
1029- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</p>")1075+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</p>")
1030 if templ_7745c5c3_Err != nil {1076 if templ_7745c5c3_Err != nil {
1031 return templ_7745c5c3_Err1077 return templ_7745c5c3_Err
1032 }1078 }
1033 }1079 }
1034- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<form class=\"state-act\" method=\"post\" action=\"")1080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form class=\"state-act\" method=\"post\" action=\"")
1035 if templ_7745c5c3_Err != nil {1081 if templ_7745c5c3_Err != nil {
1036 return templ_7745c5c3_Err1082 return templ_7745c5c3_Err
1037 }1083 }
1038- var templ_7745c5c3_Var49 templ.SafeURL1084+ var templ_7745c5c3_Var51 templ.SafeURL
1039- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))1085+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1040 if templ_7745c5c3_Err != nil {1086 if templ_7745c5c3_Err != nil {
1041- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 212, Col: 73}1087+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 73}
1042 }1088 }
1043- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1089+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1044 if templ_7745c5c3_Err != nil {1090 if templ_7745c5c3_Err != nil {
1045 return templ_7745c5c3_Err1091 return templ_7745c5c3_Err
1046 }1092 }
1047- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")1093+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">")
1048 if templ_7745c5c3_Err != nil {1094 if templ_7745c5c3_Err != nil {
1049 return templ_7745c5c3_Err1095 return templ_7745c5c3_Err
1050 }1096 }
1051- var templ_7745c5c3_Var50 string1097+ var templ_7745c5c3_Var52 string
1052- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1098+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1053 if templ_7745c5c3_Err != nil {1099 if templ_7745c5c3_Err != nil {
1054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 213, Col: 56}1100+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 56}
1055 }1101 }
1056- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))1102+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1057 if templ_7745c5c3_Err != nil {1103 if templ_7745c5c3_Err != nil {
1058 return templ_7745c5c3_Err1104 return templ_7745c5c3_Err
1059 }1105 }
1060- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1061 if templ_7745c5c3_Err != nil {1107 if templ_7745c5c3_Err != nil {
1062 return templ_7745c5c3_Err1108 return templ_7745c5c3_Err
1063 }1109 }
1064 return nil1110 return nil
1065 })1111 })
1066- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var46), templ_7745c5c3_Buffer)1112+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var48), templ_7745c5c3_Buffer)
1067 if templ_7745c5c3_Err != nil {1113 if templ_7745c5c3_Err != nil {
1068 return templ_7745c5c3_Err1114 return templ_7745c5c3_Err
1069 }1115 }
@@ -1089,12 +1135,12 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1089 }()1135 }()
1090 }1136 }
1091 ctx = templ.InitializeContext(ctx)1137 ctx = templ.InitializeContext(ctx)
1092- templ_7745c5c3_Var51 := templ.GetChildren(ctx)1138+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1093- if templ_7745c5c3_Var51 == nil {1139+ if templ_7745c5c3_Var53 == nil {
1094- templ_7745c5c3_Var51 = templ.NopComponent1140+ templ_7745c5c3_Var53 = templ.NopComponent
1095 }1141 }
1096 ctx = templ.ClearChildren(ctx)1142 ctx = templ.ClearChildren(ctx)
1097- templ_7745c5c3_Var52 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1143+ templ_7745c5c3_Var54 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1098 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1144 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1099 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1145 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1100 if !templ_7745c5c3_IsBuffer {1146 if !templ_7745c5c3_IsBuffer {
@@ -1106,88 +1152,88 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1106 }()1152 }()
1107 }1153 }
1108 ctx = templ.InitializeContext(ctx)1154 ctx = templ.InitializeContext(ctx)
1109- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")1155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1110 if templ_7745c5c3_Err != nil {1156 if templ_7745c5c3_Err != nil {
1111 return templ_7745c5c3_Err1157 return templ_7745c5c3_Err
1112 }1158 }
1113- var templ_7745c5c3_Var53 string1159+ var templ_7745c5c3_Var55 string
1114- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1160+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1115 if templ_7745c5c3_Err != nil {1161 if templ_7745c5c3_Err != nil {
1116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 33}1162+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 33}
1117 }1163 }
1118- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1164+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1119 if templ_7745c5c3_Err != nil {1165 if templ_7745c5c3_Err != nil {
1120 return templ_7745c5c3_Err1166 return templ_7745c5c3_Err
1121 }1167 }
1122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")1168+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"")
1123 if templ_7745c5c3_Err != nil {1169 if templ_7745c5c3_Err != nil {
1124 return templ_7745c5c3_Err1170 return templ_7745c5c3_Err
1125 }1171 }
1126- var templ_7745c5c3_Var54 string1172+ var templ_7745c5c3_Var56 string
1127- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1173+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1128 if templ_7745c5c3_Err != nil {1174 if templ_7745c5c3_Err != nil {
1129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 87}1175+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 87}
1130 }1176 }
1131- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)1177+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
1132 if templ_7745c5c3_Err != nil {1178 if templ_7745c5c3_Err != nil {
1133 return templ_7745c5c3_Err1179 return templ_7745c5c3_Err
1134 }1180 }
1135- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-finish=\"")1181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-finish=\"")
1136 if templ_7745c5c3_Err != nil {1182 if templ_7745c5c3_Err != nil {
1137 return templ_7745c5c3_Err1183 return templ_7745c5c3_Err
1138 }1184 }
1139- var templ_7745c5c3_Var55 string1185+ var templ_7745c5c3_Var57 string
1140- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1186+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1141 if templ_7745c5c3_Err != nil {1187 if templ_7745c5c3_Err != nil {
1142- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 112}1188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 112}
1143 }1189 }
1144- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)1190+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
1145 if templ_7745c5c3_Err != nil {1191 if templ_7745c5c3_Err != nil {
1146 return templ_7745c5c3_Err1192 return templ_7745c5c3_Err
1147 }1193 }
1148- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\">")1194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">")
1149 if templ_7745c5c3_Err != nil {1195 if templ_7745c5c3_Err != nil {
1150 return templ_7745c5c3_Err1196 return templ_7745c5c3_Err
1151 }1197 }
1152- var templ_7745c5c3_Var56 string1198+ var templ_7745c5c3_Var58 string
1153- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1199+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1154 if templ_7745c5c3_Err != nil {1200 if templ_7745c5c3_Err != nil {
1155- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 125}1201+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 125}
1156 }1202 }
1157- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1203+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1158 if templ_7745c5c3_Err != nil {1204 if templ_7745c5c3_Err != nil {
1159 return templ_7745c5c3_Err1205 return templ_7745c5c3_Err
1160 }1206 }
1161- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")1207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1162 if templ_7745c5c3_Err != nil {1208 if templ_7745c5c3_Err != nil {
1163 return templ_7745c5c3_Err1209 return templ_7745c5c3_Err
1164 }1210 }
1165 for _, line := range p.Lines {1211 for _, line := range p.Lines {
1166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"dim\">")1212+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<p class=\"dim\">")
1167 if templ_7745c5c3_Err != nil {1213 if templ_7745c5c3_Err != nil {
1168 return templ_7745c5c3_Err1214 return templ_7745c5c3_Err
1169 }1215 }
1170- var templ_7745c5c3_Var57 string1216+ var templ_7745c5c3_Var59 string
1171- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)1217+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1172 if templ_7745c5c3_Err != nil {1218 if templ_7745c5c3_Err != nil {
1173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}1219+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 239, Col: 25}
1174 }1220 }
1175- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))1221+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1176 if templ_7745c5c3_Err != nil {1222 if templ_7745c5c3_Err != nil {
1177 return templ_7745c5c3_Err1223 return templ_7745c5c3_Err
1178 }1224 }
1179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p>")1225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p>")
1180 if templ_7745c5c3_Err != nil {1226 if templ_7745c5c3_Err != nil {
1181 return templ_7745c5c3_Err1227 return templ_7745c5c3_Err
1182 }1228 }
1183 }1229 }
1184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")1230+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1185 if templ_7745c5c3_Err != nil {1231 if templ_7745c5c3_Err != nil {
1186 return templ_7745c5c3_Err1232 return templ_7745c5c3_Err
1187 }1233 }
1188 return nil1234 return nil
1189 })1235 })
1190- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var52), templ_7745c5c3_Buffer)1236+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var54), templ_7745c5c3_Buffer)
1191 if templ_7745c5c3_Err != nil {1237 if templ_7745c5c3_Err != nil {
1192 return templ_7745c5c3_Err1238 return templ_7745c5c3_Err
1193 }1239 }
@@ -1212,12 +1258,12 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1212 }()1258 }()
1213 }1259 }
1214 ctx = templ.InitializeContext(ctx)1260 ctx = templ.InitializeContext(ctx)
1215- templ_7745c5c3_Var58 := templ.GetChildren(ctx)1261+ templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1216- if templ_7745c5c3_Var58 == nil {1262+ if templ_7745c5c3_Var60 == nil {
1217- templ_7745c5c3_Var58 = templ.NopComponent1263+ templ_7745c5c3_Var60 = templ.NopComponent
1218 }1264 }
1219 ctx = templ.ClearChildren(ctx)1265 ctx = templ.ClearChildren(ctx)
1220- templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1266+ templ_7745c5c3_Var61 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1221 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1267 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1222 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)1268 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1223 if !templ_7745c5c3_IsBuffer {1269 if !templ_7745c5c3_IsBuffer {
@@ -1229,231 +1275,231 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1229 }()1275 }()
1230 }1276 }
1231 ctx = templ.InitializeContext(ctx)1277 ctx = templ.InitializeContext(ctx)
1232- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")1278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1233 if templ_7745c5c3_Err != nil {1279 if templ_7745c5c3_Err != nil {
1234 return templ_7745c5c3_Err1280 return templ_7745c5c3_Err
1235 }1281 }
1236- var templ_7745c5c3_Var60 string1282+ var templ_7745c5c3_Var62 string
1237- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))1283+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1238 if templ_7745c5c3_Err != nil {1284 if templ_7745c5c3_Err != nil {
1239- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 241, Col: 81}1285+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 81}
1240 }1286 }
1241- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1287+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1242 if templ_7745c5c3_Err != nil {1288 if templ_7745c5c3_Err != nil {
1243 return templ_7745c5c3_Err1289 return templ_7745c5c3_Err
1244 }1290 }
1245- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</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>")1291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</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>")
1246 if templ_7745c5c3_Err != nil {1292 if templ_7745c5c3_Err != nil {
1247 return templ_7745c5c3_Err1293 return templ_7745c5c3_Err
1248 }1294 }
1249 if len(p.Rows) > 0 {1295 if len(p.Rows) > 0 {
1250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<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>")1296+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<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>")
1251 if templ_7745c5c3_Err != nil {1297 if templ_7745c5c3_Err != nil {
1252 return templ_7745c5c3_Err1298 return templ_7745c5c3_Err
1253 }1299 }
1254 for _, r := range p.Rows {1300 for _, r := range p.Rows {
1255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"c-name\">")1301+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<tr><td class=\"c-name\">")
1256 if templ_7745c5c3_Err != nil {1302 if templ_7745c5c3_Err != nil {
1257 return templ_7745c5c3_Err1303 return templ_7745c5c3_Err
1258 }1304 }
1259- var templ_7745c5c3_Var61 string1305+ var templ_7745c5c3_Var63 string
1260- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1306+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1261 if templ_7745c5c3_Err != nil {1307 if templ_7745c5c3_Err != nil {
1262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 34}1308+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 34}
1263 }1309 }
1264- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))1310+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1265 if templ_7745c5c3_Err != nil {1311 if templ_7745c5c3_Err != nil {
1266 return templ_7745c5c3_Err1312 return templ_7745c5c3_Err
1267 }1313 }
1268- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</td><td class=\"c-when nowrap\"><time")1314+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</td><td class=\"c-when nowrap\"><time")
1269 if templ_7745c5c3_Err != nil {1315 if templ_7745c5c3_Err != nil {
1270 return templ_7745c5c3_Err1316 return templ_7745c5c3_Err
1271 }1317 }
1272 if r.CreatedAt != "" {1318 if r.CreatedAt != "" {
1273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " datetime=\"")1319+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " datetime=\"")
1274 if templ_7745c5c3_Err != nil {1320 if templ_7745c5c3_Err != nil {
1275 return templ_7745c5c3_Err1321 return templ_7745c5c3_Err
1276 }1322 }
1277- var templ_7745c5c3_Var62 string1323+ var templ_7745c5c3_Var64 string
1278- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)1324+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1279 if templ_7745c5c3_Err != nil {1325 if templ_7745c5c3_Err != nil {
1280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 32}1326+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 32}
1281 }1327 }
1282- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)1328+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1283 if templ_7745c5c3_Err != nil {1329 if templ_7745c5c3_Err != nil {
1284 return templ_7745c5c3_Err1330 return templ_7745c5c3_Err
1285 }1331 }
1286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\"")1332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"")
1287 if templ_7745c5c3_Err != nil {1333 if templ_7745c5c3_Err != nil {
1288 return templ_7745c5c3_Err1334 return templ_7745c5c3_Err
1289 }1335 }
1290 }1336 }
1291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, ">")1337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, ">")
1292 if templ_7745c5c3_Err != nil {1338 if templ_7745c5c3_Err != nil {
1293 return templ_7745c5c3_Err1339 return templ_7745c5c3_Err
1294 }1340 }
1295- var templ_7745c5c3_Var63 string1341+ var templ_7745c5c3_Var65 string
1296- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)1342+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1297 if templ_7745c5c3_Err != nil {1343 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 263, Col: 20}1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 271, Col: 20}
1299 }1345 }
1300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1301 if templ_7745c5c3_Err != nil {1347 if templ_7745c5c3_Err != nil {
1302 return templ_7745c5c3_Err1348 return templ_7745c5c3_Err
1303 }1349 }
1304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</time></td><td class=\"c-when nowrap\"><time")1350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</time></td><td class=\"c-when nowrap\"><time")
1305 if templ_7745c5c3_Err != nil {1351 if templ_7745c5c3_Err != nil {
1306 return templ_7745c5c3_Err1352 return templ_7745c5c3_Err
1307 }1353 }
1308 if r.LastUsedAt != "" {1354 if r.LastUsedAt != "" {
1309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " datetime=\"")1355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " datetime=\"")
1310 if templ_7745c5c3_Err != nil {1356 if templ_7745c5c3_Err != nil {
1311 return templ_7745c5c3_Err1357 return templ_7745c5c3_Err
1312 }1358 }
1313- var templ_7745c5c3_Var64 string1359+ var templ_7745c5c3_Var66 string
1314- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)1360+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1315 if templ_7745c5c3_Err != nil {1361 if templ_7745c5c3_Err != nil {
1316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 268, Col: 33}1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 276, Col: 33}
1317 }1363 }
1318- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)1364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1319 if templ_7745c5c3_Err != nil {1365 if templ_7745c5c3_Err != nil {
1320 return templ_7745c5c3_Err1366 return templ_7745c5c3_Err
1321 }1367 }
1322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"")1368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"")
1323 if templ_7745c5c3_Err != nil {1369 if templ_7745c5c3_Err != nil {
1324 return templ_7745c5c3_Err1370 return templ_7745c5c3_Err
1325 }1371 }
1326 }1372 }
1327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, ">")1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ">")
1328 if templ_7745c5c3_Err != nil {1374 if templ_7745c5c3_Err != nil {
1329 return templ_7745c5c3_Err1375 return templ_7745c5c3_Err
1330 }1376 }
1331- var templ_7745c5c3_Var65 string1377+ var templ_7745c5c3_Var67 string
1332- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)1378+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1333 if templ_7745c5c3_Err != nil {1379 if templ_7745c5c3_Err != nil {
1334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 21}1380+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 21}
1335 }1381 }
1336- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))1382+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1337 if templ_7745c5c3_Err != nil {1383 if templ_7745c5c3_Err != nil {
1338 return templ_7745c5c3_Err1384 return templ_7745c5c3_Err
1339 }1385 }
1340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</time></td><td class=\"c-sync\">")1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</time></td><td class=\"c-sync\">")
1341 if templ_7745c5c3_Err != nil {1387 if templ_7745c5c3_Err != nil {
1342 return templ_7745c5c3_Err1388 return templ_7745c5c3_Err
1343 }1389 }
1344 if r.Synced {1390 if r.Synced {
1345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "yes")1391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "yes")
1346 if templ_7745c5c3_Err != nil {1392 if templ_7745c5c3_Err != nil {
1347 return templ_7745c5c3_Err1393 return templ_7745c5c3_Err
1348 }1394 }
1349 } else {1395 } else {
1350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "no")1396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "no")
1351 if templ_7745c5c3_Err != nil {1397 if templ_7745c5c3_Err != nil {
1352 return templ_7745c5c3_Err1398 return templ_7745c5c3_Err
1353 }1399 }
1354 }1400 }
1355- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td><td class=\"c-act\"><form method=\"post\" action=\"")1401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1356 if templ_7745c5c3_Err != nil {1402 if templ_7745c5c3_Err != nil {
1357 return templ_7745c5c3_Err1403 return templ_7745c5c3_Err
1358 }1404 }
1359- var templ_7745c5c3_Var66 templ.SafeURL1405+ var templ_7745c5c3_Var68 templ.SafeURL
1360- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))1406+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1361 if templ_7745c5c3_Err != nil {1407 if templ_7745c5c3_Err != nil {
1362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 280, Col: 60}1408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 288, Col: 60}
1363 }1409 }
1364- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))1410+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1365 if templ_7745c5c3_Err != nil {1411 if templ_7745c5c3_Err != nil {
1366 return templ_7745c5c3_Err1412 return templ_7745c5c3_Err
1367 }1413 }
1368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")1414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1369 if templ_7745c5c3_Err != nil {1415 if templ_7745c5c3_Err != nil {
1370 return templ_7745c5c3_Err1416 return templ_7745c5c3_Err
1371 }1417 }
1372- var templ_7745c5c3_Var67 string1418+ var templ_7745c5c3_Var69 string
1373- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)1419+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1374 if templ_7745c5c3_Err != nil {1420 if templ_7745c5c3_Err != nil {
1375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 89}1421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 89}
1376 }1422 }
1377- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)1423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1378 if templ_7745c5c3_Err != nil {1424 if templ_7745c5c3_Err != nil {
1379 return templ_7745c5c3_Err1425 return templ_7745c5c3_Err
1380 }1426 }
1381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">Remove</button></form></td></tr>")1427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Remove</button></form></td></tr>")
1382 if templ_7745c5c3_Err != nil {1428 if templ_7745c5c3_Err != nil {
1383 return templ_7745c5c3_Err1429 return templ_7745c5c3_Err
1384 }1430 }
1385 }1431 }
1386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</tbody></table>")1432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</tbody></table>")
1387 if templ_7745c5c3_Err != nil {1433 if templ_7745c5c3_Err != nil {
1388 return templ_7745c5c3_Err1434 return templ_7745c5c3_Err
1389 }1435 }
1390 } else {1436 } else {
1391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"lede dim\">no passkeys yet.</p>")1437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<p class=\"lede dim\">no passkeys yet.</p>")
1392 if templ_7745c5c3_Err != nil {1438 if templ_7745c5c3_Err != nil {
1393 return templ_7745c5c3_Err1439 return templ_7745c5c3_Err
1394 }1440 }
1395 }1441 }
1396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ")1442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " ")
1397 if templ_7745c5c3_Err != nil {1443 if templ_7745c5c3_Err != nil {
1398 return templ_7745c5c3_Err1444 return templ_7745c5c3_Err
1399 }1445 }
1400 if p.Full {1446 if p.Full {
1401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<p class=\"dim\">")1447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">")
1402 if templ_7745c5c3_Err != nil {1448 if templ_7745c5c3_Err != nil {
1403 return templ_7745c5c3_Err1449 return templ_7745c5c3_Err
1404 }1450 }
1405- var templ_7745c5c3_Var68 string1451+ var templ_7745c5c3_Var70 string
1406- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))1452+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1407 if templ_7745c5c3_Err != nil {1453 if templ_7745c5c3_Err != nil {
1408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 292, Col: 31}1454+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 300, Col: 31}
1409 }1455 }
1410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))1456+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1411 if templ_7745c5c3_Err != nil {1457 if templ_7745c5c3_Err != nil {
1412 return templ_7745c5c3_Err1458 return templ_7745c5c3_Err
1413 }1459 }
1414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " passkeys are registered, the most there can be. Remove one to add another.</p>")1460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1415 if templ_7745c5c3_Err != nil {1461 if templ_7745c5c3_Err != nil {
1416 return templ_7745c5c3_Err1462 return templ_7745c5c3_Err
1417 }1463 }
1418 } else {1464 } else {
1419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<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=\"")1465+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<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=\"")
1420 if templ_7745c5c3_Err != nil {1466 if templ_7745c5c3_Err != nil {
1421 return templ_7745c5c3_Err1467 return templ_7745c5c3_Err
1422 }1468 }
1423- var templ_7745c5c3_Var69 string1469+ var templ_7745c5c3_Var71 string
1424- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1470+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1425 if templ_7745c5c3_Err != nil {1471 if templ_7745c5c3_Err != nil {
1426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 90}1472+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 90}
1427 }1473 }
1428- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)1474+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
1429 if templ_7745c5c3_Err != nil {1475 if templ_7745c5c3_Err != nil {
1430 return templ_7745c5c3_Err1476 return templ_7745c5c3_Err
1431 }1477 }
1432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" data-finish=\"")1478+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" data-finish=\"")
1433 if templ_7745c5c3_Err != nil {1479 if templ_7745c5c3_Err != nil {
1434 return templ_7745c5c3_Err1480 return templ_7745c5c3_Err
1435 }1481 }
1436- var templ_7745c5c3_Var70 string1482+ var templ_7745c5c3_Var72 string
1437- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1483+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1438 if templ_7745c5c3_Err != nil {1484 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 115}1485+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 115}
1440 }1486 }
1441- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)1487+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1442 if templ_7745c5c3_Err != nil {1488 if templ_7745c5c3_Err != nil {
1443 return templ_7745c5c3_Err1489 return templ_7745c5c3_Err
1444 }1490 }
1445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Add a passkey</button></form>")1491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">Add a passkey</button></form>")
1446 if templ_7745c5c3_Err != nil {1492 if templ_7745c5c3_Err != nil {
1447 return templ_7745c5c3_Err1493 return templ_7745c5c3_Err
1448 }1494 }
1449 }1495 }
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")1496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1451 if templ_7745c5c3_Err != nil {1497 if templ_7745c5c3_Err != nil {
1452 return templ_7745c5c3_Err1498 return templ_7745c5c3_Err
1453 }1499 }
1454 return nil1500 return nil
1455 })1501 })
1456- templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer)1502+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var61), templ_7745c5c3_Buffer)
1457 if templ_7745c5c3_Err != nil {1503 if templ_7745c5c3_Err != nil {
1458 return templ_7745c5c3_Err1504 return templ_7745c5c3_Err
1459 }1505 }
M internal/adapters/web/repo.go +5 -0
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131131 // layout is the Layout of the pages of a repository: the sections, the ref
132132 // switcher on the head ref and the ":" targets. The caller sets the title,
133133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135137 l := s.base()
136138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156158 l.Sections = append(l.Sections, mrs)
157159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158163 l.Sections = s.withActions(l.Sections, d.name(), section)
159164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160165
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131 // layout is the Layout of the pages of a repository: the sections, the ref131 // layout is the Layout of the pages of a repository: the sections, the ref
132 // switcher on the head ref and the ":" targets. The caller sets the title,132 // switcher on the head ref and the ":" targets. The caller sets the title,
133 // the crumbs, the status and the rail.133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135 l := s.base()137 l := s.base()
136 l.Repo = d.name()138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155 mrs.Count = s.openMRCount(ctx, d.repo.Name)157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156 l.Sections = append(l.Sections, mrs)158 l.Sections = append(l.Sections, mrs)
157 }159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158 l.Sections = s.withActions(l.Sections, d.name(), section)163 l.Sections = s.withActions(l.Sections, d.name(), section)
159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160 165
M internal/adapters/web/session.go +16 -1
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196196 if r.Method != http.MethodPost {
197197 return false
198198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200215 }
201216
202217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196 if r.Method != http.MethodPost {196 if r.Method != http.MethodPost {
197 return false197 return false
198 }198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200 }215 }
201 216
202 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
M internal/adapters/web/shell.templ +3 -0
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
3333 if l.MR {
3434 <script src={ l.Assets.MR } defer></script>
3535 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
3639 </head>
3740 <body
3841 data-ref={ l.RefPath }
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
33 if l.MR {33 if l.MR {
34 <script src={ l.Assets.MR } defer></script>34 <script src={ l.Assets.MR } defer></script>
35 }35 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
36 </head>39 </head>
37 <body40 <body
38 data-ref={ l.RefPath }41 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +180 -161 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +25 -0
@@ -171,6 +171,22 @@
171171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172172 }
173173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174190 .list.mrs {
175191 & .c-n { width: 64px; }
176192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184200 }
185201 #mr-notice:empty { display: none; }
186202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187212 @media (max-width: 700px) {
188213 .list.mrs .c-who { display: none; }
189214 }
@@ -171,6 +171,22 @@
171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172 }172 }
173 173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174 .list.mrs {190 .list.mrs {
175 & .c-n { width: 64px; }191 & .c-n { width: 64px; }
176 & .c-state { width: 110px; }192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184 }200 }
185 #mr-notice:empty { display: none; }201 #mr-notice:empty { display: none; }
186 202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187 @media (max-width: 700px) {212 @media (max-width: 700px) {
188 .list.mrs .c-who { display: none; }213 .list.mrs .c-who { display: none; }
189 }214 }
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -555,11 +555,11 @@
555555
556556 // 1. Keys.
557557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559559 // the merge request page (mr.js): the merge menu, mark read
560560 M: 'merge', R: 'read' };
561561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563563 let pending = 0;
564564 let pendingTimer = 0;
565565 addEventListener('keydown', (e) => {
@@ -555,11 +555,11 @@
555 555
556 // 1. Keys.556 // 1. Keys.
557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559 // the merge request page (mr.js): the merge menu, mark read559 // the merge request page (mr.js): the merge menu, mark read
560 M: 'merge', R: 'read' };560 M: 'merge', R: 'read' };
561 // "g t" is the key "t": the tabs have one event each.561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563 let pending = 0;563 let pending = 0;
564 let pendingTimer = 0;564 let pendingTimer = 0;
565 addEventListener('keydown', (e) => {565 addEventListener('keydown', (e) => {
A internal/adapters/web/static/js/issue-form.js +175 -0
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
A internal/adapters/web/static/js/pow-worker.js +72 -0
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
M internal/adapters/web/view.go +12 -0
@@ -69,6 +69,8 @@ type Layout struct {
6969 Passkeys bool
7070 // MR loads the script of the merge request page (mr.js).
7171 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
7274 }
7375
7476 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135137 type HomePage struct {
136138 Sorts []SortLink
137139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138150 }
139151
140152 // Fact is one key/value row of the facts block.
@@ -69,6 +69,8 @@ type Layout struct {
69 Passkeys bool69 Passkeys bool
70 // MR loads the script of the merge request page (mr.js).70 // MR loads the script of the merge request page (mr.js).
71 MR bool71 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
72 }74 }
73 75
74 // Link is a plain label + href pair.76 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135 type HomePage struct {137 type HomePage struct {
136 Sorts []SortLink138 Sorts []SortLink
137 Rows []HomeRow139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138 }150 }
139 151
140 // Fact is one key/value row of the facts block.152 // Fact is one key/value row of the facts block.
M internal/adapters/web/web.go +46 -1
@@ -54,6 +54,28 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
5779 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
5880 // commits and merge requests. Without it there are no such pages. Leave it
5981 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166188 if s.passkeysOn() {
167189 s.passkeyRoutes(mux)
168190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169199 mux.HandleFunc("GET /{$}", s.page(s.home))
170200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182212 if s.cfg.MergeRequests != nil {
183213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200245 if s.cfg.Runs != nil {
201246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -54,6 +54,28 @@ type Config struct {
54 // there are no such pages, nor section for them. Leave it unset rather54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
57 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to79 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58 // commits and merge requests. Without it there are no such pages. Leave it80 // commits and merge requests. Without it there are no such pages. Leave it
59 // unset rather than assigning a typed nil pointer.81 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166 if s.passkeysOn() {188 if s.passkeysOn() {
167 s.passkeyRoutes(mux)189 s.passkeyRoutes(mux)
168 }190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169 mux.HandleFunc("GET /{$}", s.page(s.home))199 mux.HandleFunc("GET /{$}", s.page(s.home))
170 mux.HandleFunc("GET /{repo}", s.page(s.summary))200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182 if s.cfg.MergeRequests != nil {212 if s.cfg.MergeRequests != nil {
183 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199 }229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200 if s.cfg.Runs != nil {245 if s.cfg.Runs != nil {
201 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/api_helpers_test.go +2 -0
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +27 -0
A internal/app/cli_issue.go +455 -0
A internal/app/cli_issue_e2e_test.go +154 -0
A internal/app/cli_issue_test.go +264 -0
M internal/app/cli_repo.go +61 -6
M internal/app/cli_repo_test.go +6 -5
M internal/app/cli_token.go +22 -0
M internal/app/cli_token_test.go +2 -3
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch_test.go +1 -1
M internal/app/forge.go +71 -22
A internal/app/issue_api_test.go +245 -0
A internal/app/issue_moderation_api_test.go +139 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +3 -1
M internal/app/testdata/web/blob-code.html +3 -1
M internal/app/testdata/web/blob-empty.html +3 -1
M internal/app/testdata/web/blob-image.html +3 -1
M internal/app/testdata/web/blob-markdown.html +3 -1
M internal/app/testdata/web/blob-svg.html +3 -1
M internal/app/testdata/web/blob-text.html +3 -1
M internal/app/testdata/web/blob-too-large.html +3 -1
M internal/app/testdata/web/blob-trojan.html +3 -1
M internal/app/testdata/web/commit-big.html +3 -1
M internal/app/testdata/web/commit-binary.html +3 -1
M internal/app/testdata/web/commit-merge.html +3 -1
M internal/app/testdata/web/commit-modify-split.html +3 -1
M internal/app/testdata/web/commit-modify.html +3 -1
M internal/app/testdata/web/commit-rename.html +3 -1
M internal/app/testdata/web/commit-root.html +3 -1
M internal/app/testdata/web/commit-trojan.html +3 -1
M internal/app/testdata/web/commits-page2.html +3 -1
M internal/app/testdata/web/commits-path.html +3 -1
M internal/app/testdata/web/commits-root.html +4 -2
M internal/app/testdata/web/commits-small.html +4 -2
M internal/app/testdata/web/empty.html +3 -1
M internal/app/testdata/web/home.html +4 -2
M internal/app/testdata/web/not-found-page.html +3 -1
M internal/app/testdata/web/not-found.html +3 -1
M internal/app/testdata/web/refs.html +4 -2
M internal/app/testdata/web/summary.html +4 -2
M internal/app/testdata/web/tree-nested.html +3 -1
M internal/app/testdata/web/tree-root.html +3 -1
M internal/app/testdata/web/tree-slash-ref.html +3 -1
M internal/app/testdata/web/tree-subdir.html +3 -1
M internal/app/web.go +5 -0
A internal/app/web_inbox_test.go +152 -0
A internal/app/web_issue_form_test.go +231 -0
A internal/app/web_issue_test.go +139 -0
M internal/clientlimit/export_test.go +7 -0
A internal/clientlimit/submissions.go +178 -0
A internal/clientlimit/submissions_test.go +148 -0
M internal/domain/event.go +72 -0
A internal/domain/issue.go +106 -0
A internal/domain/issue_test.go +138 -0
M internal/domain/mergerequest.go +3 -1
A internal/domain/pending_issue.go +122 -0
A internal/domain/pending_issue_test.go +90 -0
A internal/domain/submission.go +42 -0
M internal/domain/text.go +102 -0
M internal/domain/token.go +5 -1
M internal/domain/token_test.go +6 -1
M internal/ports/dto.go +69 -0
A internal/ports/mocks/InboxService.go +121 -0
A internal/ports/mocks/IssueBanStore.go +183 -0
A internal/ports/mocks/IssueKeyStore.go +109 -0
A internal/ports/mocks/IssueModerationService.go +280 -0
A internal/ports/mocks/IssueNumberResolver.go +126 -0
A internal/ports/mocks/IssueService.go +523 -0
A internal/ports/mocks/IssueStore.go +515 -0
A internal/ports/mocks/IssueSubmissionService.go +190 -0
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close