detached e86dbd6

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

- feat(web): document tabs and main-content navigation on the summary
- fix(web): select the summary doc by exact file name first
- feat(web): show cached repository licenses and latest release badges
- fix(web): new activity pill expires after a minute on the index
- feat(web): small refresh icon instead of the new activity pill, expires after a minute
- style(web): CI mark alone on the index, state in the tooltip
- style(web): smaller badge text
- merge(web): combine summary docs and index badges
- fix(web): refresh the index row on release events
- fix(web): tell MIT-0 from MIT in license detection
- test(web): mock the activity timer in the a11y test
- test(web): summary golden with the labelled document card and no rail cursor
- chore(web): mark renderDoc as display-only for hexcheck
- style(web): link pills without underline, lighter border on hover
- fix(web): keep the row hover underline off the name pills
- fix(web): count ids and entry overhead in the path and license cache budget
commit
e86dbd6dcb13c995198e6c231b1771aab474a884
author
brice · · 2026-10-22 12:20 UTC
committer
ermite · · 2026-10-22 13:11 UTC
parent
6a915d6
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
4/4
refs
tag: v0.35.0
27 files · +1694 -728
M internal/adapters/web/ASSETS.md +1 -1
@@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime.
1919 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
2020 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
2121 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
22-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
22+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `refresh-cw`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `refresh-cw`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
2323 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
2424 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
2525 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
@@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime.
19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
21 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |21 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
22-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |22+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `refresh-cw`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `refresh-cw`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
23 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |23 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
24 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |24 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
25 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |25 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
M internal/adapters/web/components.templ +27 -9
@@ -41,11 +41,17 @@ templ HomeRowView(r HomeRow) {
4141 <div class="repo-main">
4242 <h2 class="repo-name">
4343 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
44+ if rel := r.Release; rel != nil {
45+ <a class="pill repo-release" href={ templ.SafeURL(rel.Href) } title={ "Latest release: " + rel.Tag }>{ rel.Tag }</a>
46+ }
4447 if r.Private {
4548 <span class="pill">private</span>
4649 }
50+ if lic := r.License; lic != nil {
51+ <a class="pill repo-license" href={ templ.SafeURL(lic.Href) } title={ "License: " + lic.ID }>{ lic.ID }</a>
52+ }
4753 if r.Fresh {
48- <span class="pill new">new activity</span>
54+ <span class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span>
4955 }
5056 </h2>
5157 if r.Desc != "" {
@@ -94,9 +100,9 @@ templ HomeRowView(r HomeRow) {
94100 }
95101 </dl>
96102 if r.CI != nil {
97- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
103+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Label + " · " + r.CI.Title }>
98104 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
99- <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span>
105+ <span class="sr">{ "CI: " + r.CI.Label }</span>
100106 </a>
101107 }
102108 </div>
@@ -153,12 +159,24 @@ templ Outline(headings []Heading) {
153159 }
154160
155161 // ReadmeCard is a README in a card whose header names the file.
156-templ ReadmeCard(r Readme) {
157- <section class="card readme" aria-labelledby="readme-h">
158- <h2 class="card-h" id="readme-h">
159- <span class="ico i-file" aria-hidden="true"></span>
160- { r.Name }
161- </h2>
162+templ ReadmeCard(r Readme, docs ...DocTab) {
163+ <section class="card readme" aria-label={ r.Name }>
164+ if len(docs) > 1 {
165+ <nav class="card-h doc-tabs" aria-label="Repository documents">
166+ for _, doc := range docs {
167+ <a href={ templ.SafeURL(doc.Href) }
168+ if doc.Current {
169+ aria-current="page"
170+ }
171+ >{ doc.Name }</a>
172+ }
173+ </nav>
174+ } else {
175+ <h2 class="card-h" id="readme-h">
176+ <span class="ico i-file" aria-hidden="true"></span>
177+ { r.Name }
178+ </h2>
179+ }
162180 @ReadmeView(r)
163181 </section>
164182 }
@@ -41,11 +41,17 @@ templ HomeRowView(r HomeRow) {
41 <div class="repo-main">41 <div class="repo-main">
42 <h2 class="repo-name">42 <h2 class="repo-name">
43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
44+ if rel := r.Release; rel != nil {
45+ <a class="pill repo-release" href={ templ.SafeURL(rel.Href) } title={ "Latest release: " + rel.Tag }>{ rel.Tag }</a>
46+ }
44 if r.Private {47 if r.Private {
45 <span class="pill">private</span>48 <span class="pill">private</span>
46 }49 }
50+ if lic := r.License; lic != nil {
51+ <a class="pill repo-license" href={ templ.SafeURL(lic.Href) } title={ "License: " + lic.ID }>{ lic.ID }</a>
52+ }
47 if r.Fresh {53 if r.Fresh {
48- <span class="pill new">new activity</span>54+ <span class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span>
49 }55 }
50 </h2>56 </h2>
51 if r.Desc != "" {57 if r.Desc != "" {
@@ -94,9 +100,9 @@ templ HomeRowView(r HomeRow) {
94 }100 }
95 </dl>101 </dl>
96 if r.CI != nil {102 if r.CI != nil {
97- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>103+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Label + " · " + r.CI.Title }>
98 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>104 <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
99- <span class="sr">CI: </span><span class="ci-label">{ r.CI.Label }</span>105+ <span class="sr">{ "CI: " + r.CI.Label }</span>
100 </a>106 </a>
101 }107 }
102 </div>108 </div>
@@ -153,12 +159,24 @@ templ Outline(headings []Heading) {
153 }159 }
154 160
155 // ReadmeCard is a README in a card whose header names the file.161 // ReadmeCard is a README in a card whose header names the file.
156-templ ReadmeCard(r Readme) {162+templ ReadmeCard(r Readme, docs ...DocTab) {
157- <section class="card readme" aria-labelledby="readme-h">163+ <section class="card readme" aria-label={ r.Name }>
158- <h2 class="card-h" id="readme-h">164+ if len(docs) > 1 {
159- <span class="ico i-file" aria-hidden="true"></span>165+ <nav class="card-h doc-tabs" aria-label="Repository documents">
160- { r.Name }166+ for _, doc := range docs {
161- </h2>167+ <a href={ templ.SafeURL(doc.Href) }
168+ if doc.Current {
169+ aria-current="page"
170+ }
171+ >{ doc.Name }</a>
172+ }
173+ </nav>
174+ } else {
175+ <h2 class="card-h" id="readme-h">
176+ <span class="ico i-file" aria-hidden="true"></span>
177+ { r.Name }
178+ </h2>
179+ }
162 @ReadmeView(r)180 @ReadmeView(r)
163 </section>181 </section>
164 }182 }
M internal/adapters/web/components_templ.go +504 -344
@@ -231,89 +231,179 @@ func HomeRowView(r HomeRow) templ.Component {
231231 if templ_7745c5c3_Err != nil {
232232 return templ_7745c5c3_Err
233233 }
234+ if rel := r.Release; rel != nil {
235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a class=\"pill repo-release\" href=\"")
236+ if templ_7745c5c3_Err != nil {
237+ return templ_7745c5c3_Err
238+ }
239+ var templ_7745c5c3_Var11 templ.SafeURL
240+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(rel.Href))
241+ if templ_7745c5c3_Err != nil {
242+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 45, Col: 64}
243+ }
244+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
245+ if templ_7745c5c3_Err != nil {
246+ return templ_7745c5c3_Err
247+ }
248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"")
249+ if templ_7745c5c3_Err != nil {
250+ return templ_7745c5c3_Err
251+ }
252+ var templ_7745c5c3_Var12 string
253+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Latest release: " + rel.Tag)
254+ if templ_7745c5c3_Err != nil {
255+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 45, Col: 103}
256+ }
257+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
258+ if templ_7745c5c3_Err != nil {
259+ return templ_7745c5c3_Err
260+ }
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
262+ if templ_7745c5c3_Err != nil {
263+ return templ_7745c5c3_Err
264+ }
265+ var templ_7745c5c3_Var13 string
266+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Tag)
267+ if templ_7745c5c3_Err != nil {
268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 45, Col: 115}
269+ }
270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
271+ if templ_7745c5c3_Err != nil {
272+ return templ_7745c5c3_Err
273+ }
274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a> ")
275+ if templ_7745c5c3_Err != nil {
276+ return templ_7745c5c3_Err
277+ }
278+ }
234279 if r.Private {
235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"pill\">private</span> ")
280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"pill\">private</span> ")
281+ if templ_7745c5c3_Err != nil {
282+ return templ_7745c5c3_Err
283+ }
284+ }
285+ if lic := r.License; lic != nil {
286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a class=\"pill repo-license\" href=\"")
287+ if templ_7745c5c3_Err != nil {
288+ return templ_7745c5c3_Err
289+ }
290+ var templ_7745c5c3_Var14 templ.SafeURL
291+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(lic.Href))
292+ if templ_7745c5c3_Err != nil {
293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 64}
294+ }
295+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
296+ if templ_7745c5c3_Err != nil {
297+ return templ_7745c5c3_Err
298+ }
299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" title=\"")
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ var templ_7745c5c3_Var15 string
304+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("License: " + lic.ID)
305+ if templ_7745c5c3_Err != nil {
306+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 95}
307+ }
308+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
309+ if templ_7745c5c3_Err != nil {
310+ return templ_7745c5c3_Err
311+ }
312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
313+ if templ_7745c5c3_Err != nil {
314+ return templ_7745c5c3_Err
315+ }
316+ var templ_7745c5c3_Var16 string
317+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lic.ID)
318+ if templ_7745c5c3_Err != nil {
319+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 106}
320+ }
321+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
322+ if templ_7745c5c3_Err != nil {
323+ return templ_7745c5c3_Err
324+ }
325+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a> ")
236326 if templ_7745c5c3_Err != nil {
237327 return templ_7745c5c3_Err
238328 }
239329 }
240330 if r.Fresh {
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"pill new\">new activity</span>")
331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"repo-activity\" title=\"new activity\"><span class=\"ico i-refresh-cw\" aria-hidden=\"true\"></span><span class=\"sr\">new activity</span></span>")
242332 if templ_7745c5c3_Err != nil {
243333 return templ_7745c5c3_Err
244334 }
245335 }
246- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h2>")
336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</h2>")
247337 if templ_7745c5c3_Err != nil {
248338 return templ_7745c5c3_Err
249339 }
250340 if r.Desc != "" {
251- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p class=\"repo-desc\">")
341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<p class=\"repo-desc\">")
252342 if templ_7745c5c3_Err != nil {
253343 return templ_7745c5c3_Err
254344 }
255- var templ_7745c5c3_Var11 string
256- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
345+ var templ_7745c5c3_Var17 string
346+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
257347 if templ_7745c5c3_Err != nil {
258- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 52, Col: 33}
348+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 33}
259349 }
260- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
350+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
261351 if templ_7745c5c3_Err != nil {
262352 return templ_7745c5c3_Err
263353 }
264- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p>")
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p>")
265355 if templ_7745c5c3_Err != nil {
266356 return templ_7745c5c3_Err
267357 }
268358 } else {
269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"repo-desc mute\">no description</p>")
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<p class=\"repo-desc mute\">no description</p>")
270360 if templ_7745c5c3_Err != nil {
271361 return templ_7745c5c3_Err
272362 }
273363 }
274364 if len(r.Topics) > 0 {
275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<ul class=\"repo-topics\" aria-label=\"")
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<ul class=\"repo-topics\" aria-label=\"")
276366 if templ_7745c5c3_Err != nil {
277367 return templ_7745c5c3_Err
278368 }
279- var templ_7745c5c3_Var12 string
280- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Topics of " + r.Name)
369+ var templ_7745c5c3_Var18 string
370+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Topics of " + r.Name)
281371 if templ_7745c5c3_Err != nil {
282- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 62}
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 62}
283373 }
284- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
285375 if templ_7745c5c3_Err != nil {
286376 return templ_7745c5c3_Err
287377 }
288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-fit>")
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" data-fit>")
289379 if templ_7745c5c3_Err != nil {
290380 return templ_7745c5c3_Err
291381 }
292382 for _, t := range r.Topics {
293- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<li>")
383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<li>")
294384 if templ_7745c5c3_Err != nil {
295385 return templ_7745c5c3_Err
296386 }
297- var templ_7745c5c3_Var13 string
298- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t)
387+ var templ_7745c5c3_Var19 string
388+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(t)
299389 if templ_7745c5c3_Err != nil {
300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 59, Col: 13}
390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 13}
301391 }
302- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
303393 if templ_7745c5c3_Err != nil {
304394 return templ_7745c5c3_Err
305395 }
306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</li>")
396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</li>")
307397 if templ_7745c5c3_Err != nil {
308398 return templ_7745c5c3_Err
309399 }
310400 }
311- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul>")
401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</ul>")
312402 if templ_7745c5c3_Err != nil {
313403 return templ_7745c5c3_Err
314404 }
315405 } else {
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"repo-topics\" aria-hidden=\"true\"></div>")
406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"repo-topics\" aria-hidden=\"true\"></div>")
317407 if templ_7745c5c3_Err != nil {
318408 return templ_7745c5c3_Err
319409 }
@@ -324,425 +414,425 @@ func HomeRowView(r HomeRow) templ.Component {
324414 return templ_7745c5c3_Err
325415 }
326416 } else {
327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"repo-pulse\" aria-hidden=\"true\"></div>")
417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"repo-pulse\" aria-hidden=\"true\"></div>")
328418 if templ_7745c5c3_Err != nil {
329419 return templ_7745c5c3_Err
330420 }
331421 }
332- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"repo-head\"><dl class=\"repo-stats\" aria-label=\"")
422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div><div class=\"repo-head\"><dl class=\"repo-stats\" aria-label=\"")
333423 if templ_7745c5c3_Err != nil {
334424 return templ_7745c5c3_Err
335425 }
336- var templ_7745c5c3_Var14 string
337- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
426+ var templ_7745c5c3_Var20 string
427+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
338428 if templ_7745c5c3_Err != nil {
339- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 72, Col: 61}
429+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 78, Col: 61}
340430 }
341- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
431+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
342432 if templ_7745c5c3_Err != nil {
343433 return templ_7745c5c3_Err
344434 }
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
346436 if templ_7745c5c3_Err != nil {
347437 return templ_7745c5c3_Err
348438 }
349439 if s := r.Stats; s != nil {
350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div><dt>commits</dt><dd>")
440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div><dt>commits</dt><dd>")
351441 if templ_7745c5c3_Err != nil {
352442 return templ_7745c5c3_Err
353443 }
354- var templ_7745c5c3_Var15 string
355- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
444+ var templ_7745c5c3_Var21 string
445+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
356446 if templ_7745c5c3_Err != nil {
357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 74, Col: 41}
447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 41}
358448 }
359- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
360450 if templ_7745c5c3_Err != nil {
361451 return templ_7745c5c3_Err
362452 }
363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</dd></div><div><dt>last 30 days</dt><dd>")
453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</dd></div><div><dt>last 30 days</dt><dd>")
364454 if templ_7745c5c3_Err != nil {
365455 return templ_7745c5c3_Err
366456 }
367- var templ_7745c5c3_Var16 string
368- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
457+ var templ_7745c5c3_Var22 string
458+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
369459 if templ_7745c5c3_Err != nil {
370- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 75, Col: 45}
460+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 45}
371461 }
372- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
462+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
373463 if templ_7745c5c3_Err != nil {
374464 return templ_7745c5c3_Err
375465 }
376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dd></div>")
466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</dd></div>")
377467 if templ_7745c5c3_Err != nil {
378468 return templ_7745c5c3_Err
379469 }
380470 }
381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"rs-active\"><dt>last activity</dt>")
471+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"rs-active\"><dt>last activity</dt>")
382472 if templ_7745c5c3_Err != nil {
383473 return templ_7745c5c3_Err
384474 }
385475 if r.ActiveAt != "" {
386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<dd><time datetime=\"")
476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<dd><time datetime=\"")
387477 if templ_7745c5c3_Err != nil {
388478 return templ_7745c5c3_Err
389479 }
390- var templ_7745c5c3_Var17 string
391- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
480+ var templ_7745c5c3_Var23 string
481+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
392482 if templ_7745c5c3_Err != nil {
393- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 37}
483+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 37}
394484 }
395- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
485+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
396486 if templ_7745c5c3_Err != nil {
397487 return templ_7745c5c3_Err
398488 }
399- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" title=\"")
489+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" title=\"")
400490 if templ_7745c5c3_Err != nil {
401491 return templ_7745c5c3_Err
402492 }
403- var templ_7745c5c3_Var18 string
404- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
493+ var templ_7745c5c3_Var24 string
494+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
405495 if templ_7745c5c3_Err != nil {
406- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 58}
496+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 58}
407497 }
408- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
498+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
409499 if templ_7745c5c3_Err != nil {
410500 return templ_7745c5c3_Err
411501 }
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
502+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
413503 if templ_7745c5c3_Err != nil {
414504 return templ_7745c5c3_Err
415505 }
416- var templ_7745c5c3_Var19 string
417- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
506+ var templ_7745c5c3_Var25 string
507+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
418508 if templ_7745c5c3_Err != nil {
419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 71}
509+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 71}
420510 }
421- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
511+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
422512 if templ_7745c5c3_Err != nil {
423513 return templ_7745c5c3_Err
424514 }
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</time></dd>")
515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</time></dd>")
426516 if templ_7745c5c3_Err != nil {
427517 return templ_7745c5c3_Err
428518 }
429519 } else {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dd class=\"mute\">never</dd>")
520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<dd class=\"mute\">never</dd>")
431521 if templ_7745c5c3_Err != nil {
432522 return templ_7745c5c3_Err
433523 }
434524 }
435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
525+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
436526 if templ_7745c5c3_Err != nil {
437527 return templ_7745c5c3_Err
438528 }
439529 if s := r.Stats; s != nil && s.Tag != "" {
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"rs-tag\"><dt>last tag</dt>")
530+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"rs-tag\"><dt>last tag</dt>")
441531 if templ_7745c5c3_Err != nil {
442532 return templ_7745c5c3_Err
443533 }
444534 if s.TagHref != "" {
445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<dd><a href=\"")
535+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<dd><a href=\"")
446536 if templ_7745c5c3_Err != nil {
447537 return templ_7745c5c3_Err
448538 }
449- var templ_7745c5c3_Var20 templ.SafeURL
450- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
539+ var templ_7745c5c3_Var26 templ.SafeURL
540+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
451541 if templ_7745c5c3_Err != nil {
452- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 45}
542+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 45}
453543 }
454- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
544+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
455545 if templ_7745c5c3_Err != nil {
456546 return templ_7745c5c3_Err
457547 }
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
548+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" title=\"")
459549 if templ_7745c5c3_Err != nil {
460550 return templ_7745c5c3_Err
461551 }
462- var templ_7745c5c3_Var21 string
463- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
552+ var templ_7745c5c3_Var27 string
553+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
464554 if templ_7745c5c3_Err != nil {
465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 87}
555+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 87}
466556 }
467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
557+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
468558 if templ_7745c5c3_Err != nil {
469559 return templ_7745c5c3_Err
470560 }
471- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">")
472562 if templ_7745c5c3_Err != nil {
473563 return templ_7745c5c3_Err
474564 }
475- var templ_7745c5c3_Var22 string
476- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
565+ var templ_7745c5c3_Var28 string
566+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
477567 if templ_7745c5c3_Err != nil {
478- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 97}
568+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 97}
479569 }
480- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
570+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
481571 if templ_7745c5c3_Err != nil {
482572 return templ_7745c5c3_Err
483573 }
484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</a></dd>")
574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</a></dd>")
485575 if templ_7745c5c3_Err != nil {
486576 return templ_7745c5c3_Err
487577 }
488578 } else {
489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<dd title=\"")
579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<dd title=\"")
490580 if templ_7745c5c3_Err != nil {
491581 return templ_7745c5c3_Err
492582 }
493- var templ_7745c5c3_Var23 string
494- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
583+ var templ_7745c5c3_Var29 string
584+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
495585 if templ_7745c5c3_Err != nil {
496- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 50}
586+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 50}
497587 }
498- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
588+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
499589 if templ_7745c5c3_Err != nil {
500590 return templ_7745c5c3_Err
501591 }
502- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
592+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
503593 if templ_7745c5c3_Err != nil {
504594 return templ_7745c5c3_Err
505595 }
506- var templ_7745c5c3_Var24 string
507- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
596+ var templ_7745c5c3_Var30 string
597+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
508598 if templ_7745c5c3_Err != nil {
509- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 60}
599+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 60}
510600 }
511- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
601+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
512602 if templ_7745c5c3_Err != nil {
513603 return templ_7745c5c3_Err
514604 }
515- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</dd>")
605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</dd>")
516606 if templ_7745c5c3_Err != nil {
517607 return templ_7745c5c3_Err
518608 }
519609 }
520- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")
521611 if templ_7745c5c3_Err != nil {
522612 return templ_7745c5c3_Err
523613 }
524614 }
525- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</dl>")
615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</dl>")
526616 if templ_7745c5c3_Err != nil {
527617 return templ_7745c5c3_Err
528618 }
529619 if r.CI != nil {
530- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a class=\"repo-ci\" href=\"")
620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a class=\"repo-ci\" href=\"")
531621 if templ_7745c5c3_Err != nil {
532622 return templ_7745c5c3_Err
533623 }
534- var templ_7745c5c3_Var25 templ.SafeURL
535- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
624+ var templ_7745c5c3_Var31 templ.SafeURL
625+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
536626 if templ_7745c5c3_Err != nil {
537- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 54}
627+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 54}
538628 }
539- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
629+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
540630 if templ_7745c5c3_Err != nil {
541631 return templ_7745c5c3_Err
542632 }
543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" title=\"")
633+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" title=\"")
544634 if templ_7745c5c3_Err != nil {
545635 return templ_7745c5c3_Err
546636 }
547- var templ_7745c5c3_Var26 string
548- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
637+ var templ_7745c5c3_Var32 string
638+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Label + " · " + r.CI.Title)
549639 if templ_7745c5c3_Err != nil {
550- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 75}
640+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 97}
551641 }
552- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
642+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
553643 if templ_7745c5c3_Err != nil {
554644 return templ_7745c5c3_Err
555645 }
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
646+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
557647 if templ_7745c5c3_Err != nil {
558648 return templ_7745c5c3_Err
559649 }
560- var templ_7745c5c3_Var27 = []any{"ck", "ck-" + r.CI.Kind}
561- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...)
650+ var templ_7745c5c3_Var33 = []any{"ck", "ck-" + r.CI.Kind}
651+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...)
562652 if templ_7745c5c3_Err != nil {
563653 return templ_7745c5c3_Err
564654 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"")
655+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<span class=\"")
566656 if templ_7745c5c3_Err != nil {
567657 return templ_7745c5c3_Err
568658 }
569- var templ_7745c5c3_Var28 string
570- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var27).String())
659+ var templ_7745c5c3_Var34 string
660+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var33).String())
571661 if templ_7745c5c3_Err != nil {
572662 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
573663 }
574- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
664+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
575665 if templ_7745c5c3_Err != nil {
576666 return templ_7745c5c3_Err
577667 }
578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span><span class=\"ci-label\">")
668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" aria-hidden=\"true\"></span> <span class=\"sr\">")
579669 if templ_7745c5c3_Err != nil {
580670 return templ_7745c5c3_Err
581671 }
582- var templ_7745c5c3_Var29 string
583- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
672+ var templ_7745c5c3_Var35 string
673+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs("CI: " + r.CI.Label)
584674 if templ_7745c5c3_Err != nil {
585- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 99, Col: 68}
675+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 105, Col: 43}
586676 }
587- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
677+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
588678 if templ_7745c5c3_Err != nil {
589679 return templ_7745c5c3_Err
590680 }
591- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span></a>")
681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></a>")
592682 if templ_7745c5c3_Err != nil {
593683 return templ_7745c5c3_Err
594684 }
595685 }
596- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")
686+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
597687 if templ_7745c5c3_Err != nil {
598688 return templ_7745c5c3_Err
599689 }
600690 if len(r.Commits) > 0 {
601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<ul class=\"repo-commits\"")
691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<ul class=\"repo-commits\"")
602692 if templ_7745c5c3_Err != nil {
603693 return templ_7745c5c3_Err
604694 }
605695 if len(r.Commits) == homeRecent {
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " data-full")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " data-full")
607697 if templ_7745c5c3_Err != nil {
608698 return templ_7745c5c3_Err
609699 }
610700 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " aria-label=\"")
701+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " aria-label=\"")
612702 if templ_7745c5c3_Err != nil {
613703 return templ_7745c5c3_Err
614704 }
615- var templ_7745c5c3_Var30 string
616- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
705+ var templ_7745c5c3_Var36 string
706+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
617707 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 107, Col: 46}
708+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 46}
619709 }
620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
710+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
621711 if templ_7745c5c3_Err != nil {
622712 return templ_7745c5c3_Err
623713 }
624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
625715 if templ_7745c5c3_Err != nil {
626716 return templ_7745c5c3_Err
627717 }
628718 for _, c := range r.Commits {
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<li><a class=\"rc-sha\" href=\"")
719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<li><a class=\"rc-sha\" href=\"")
630720 if templ_7745c5c3_Err != nil {
631721 return templ_7745c5c3_Err
632722 }
633- var templ_7745c5c3_Var31 templ.SafeURL
634- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
723+ var templ_7745c5c3_Var37 templ.SafeURL
724+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
635725 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 111, Col: 53}
726+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 53}
637727 }
638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
728+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
639729 if templ_7745c5c3_Err != nil {
640730 return templ_7745c5c3_Err
641731 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">")
732+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\">")
643733 if templ_7745c5c3_Err != nil {
644734 return templ_7745c5c3_Err
645735 }
646- var templ_7745c5c3_Var32 string
647- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
736+ var templ_7745c5c3_Var38 string
737+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
648738 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 111, Col: 65}
739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 65}
650740 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
741+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
652742 if templ_7745c5c3_Err != nil {
653743 return templ_7745c5c3_Err
654744 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</a> <span class=\"rc-subject\" title=\"")
745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</a> <span class=\"rc-subject\" title=\"")
656746 if templ_7745c5c3_Err != nil {
657747 return templ_7745c5c3_Err
658748 }
659- var templ_7745c5c3_Var33 string
660- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
749+ var templ_7745c5c3_Var39 string
750+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
661751 if templ_7745c5c3_Err != nil {
662- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 112, Col: 48}
752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 48}
663753 }
664- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
754+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
665755 if templ_7745c5c3_Err != nil {
666756 return templ_7745c5c3_Err
667757 }
668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
669759 if templ_7745c5c3_Err != nil {
670760 return templ_7745c5c3_Err
671761 }
672- var templ_7745c5c3_Var34 string
673- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
762+ var templ_7745c5c3_Var40 string
763+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
674764 if templ_7745c5c3_Err != nil {
675- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 112, Col: 62}
765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 62}
676766 }
677- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
678768 if templ_7745c5c3_Err != nil {
679769 return templ_7745c5c3_Err
680770 }
681- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span> ")
771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> ")
682772 if templ_7745c5c3_Err != nil {
683773 return templ_7745c5c3_Err
684774 }
685775 if c.WhenAt != "" {
686- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<time class=\"dim\" datetime=\"")
776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<time class=\"dim\" datetime=\"")
687777 if templ_7745c5c3_Err != nil {
688778 return templ_7745c5c3_Err
689779 }
690- var templ_7745c5c3_Var35 string
691- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
780+ var templ_7745c5c3_Var41 string
781+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
692782 if templ_7745c5c3_Err != nil {
693- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 114, Col: 44}
783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 120, Col: 44}
694784 }
695- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
696786 if templ_7745c5c3_Err != nil {
697787 return templ_7745c5c3_Err
698788 }
699- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" title=\"")
789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" title=\"")
700790 if templ_7745c5c3_Err != nil {
701791 return templ_7745c5c3_Err
702792 }
703- var templ_7745c5c3_Var36 string
704- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
793+ var templ_7745c5c3_Var42 string
794+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
705795 if templ_7745c5c3_Err != nil {
706- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 114, Col: 63}
796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 120, Col: 63}
707797 }
708- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
709799 if templ_7745c5c3_Err != nil {
710800 return templ_7745c5c3_Err
711801 }
712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\">")
713803 if templ_7745c5c3_Err != nil {
714804 return templ_7745c5c3_Err
715805 }
716- var templ_7745c5c3_Var37 string
717- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
806+ var templ_7745c5c3_Var43 string
807+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
718808 if templ_7745c5c3_Err != nil {
719- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 114, Col: 74}
809+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 120, Col: 74}
720810 }
721- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
811+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
722812 if templ_7745c5c3_Err != nil {
723813 return templ_7745c5c3_Err
724814 }
725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</time>")
815+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</time>")
726816 if templ_7745c5c3_Err != nil {
727817 return templ_7745c5c3_Err
728818 }
729819 }
730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</li>")
820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</li>")
731821 if templ_7745c5c3_Err != nil {
732822 return templ_7745c5c3_Err
733823 }
734824 }
735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</ul>")
825+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</ul>")
736826 if templ_7745c5c3_Err != nil {
737827 return templ_7745c5c3_Err
738828 }
739829 } else if r.NoCommits {
740- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"repo-commits mute\">no commits yet</p>")
830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"repo-commits mute\">no commits yet</p>")
741831 if templ_7745c5c3_Err != nil {
742832 return templ_7745c5c3_Err
743833 }
744834 }
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</li>")
835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</li>")
746836 if templ_7745c5c3_Err != nil {
747837 return templ_7745c5c3_Err
748838 }
@@ -770,113 +860,113 @@ func pulse(h *Heartbeat) templ.Component {
770860 }()
771861 }
772862 ctx = templ.InitializeContext(ctx)
773- templ_7745c5c3_Var38 := templ.GetChildren(ctx)
774- if templ_7745c5c3_Var38 == nil {
775- templ_7745c5c3_Var38 = templ.NopComponent
863+ templ_7745c5c3_Var44 := templ.GetChildren(ctx)
864+ if templ_7745c5c3_Var44 == nil {
865+ templ_7745c5c3_Var44 = templ.NopComponent
776866 }
777867 ctx = templ.ClearChildren(ctx)
778- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<figure class=\"repo-pulse\"")
868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<figure class=\"repo-pulse\"")
779869 if templ_7745c5c3_Err != nil {
780870 return templ_7745c5c3_Err
781871 }
782872 if h.Live {
783- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " data-live")
873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " data-live")
784874 if templ_7745c5c3_Err != nil {
785875 return templ_7745c5c3_Err
786876 }
787877 }
788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "><svg viewBox=\"0 0 600 64\" role=\"img\" aria-label=\"")
878+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "><svg viewBox=\"0 0 600 64\" role=\"img\" aria-label=\"")
789879 if templ_7745c5c3_Err != nil {
790880 return templ_7745c5c3_Err
791881 }
792- var templ_7745c5c3_Var39 string
793- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Label)
882+ var templ_7745c5c3_Var45 string
883+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Label)
794884 if templ_7745c5c3_Err != nil {
795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 131, Col: 59}
885+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 59}
796886 }
797- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
887+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
798888 if templ_7745c5c3_Err != nil {
799889 return templ_7745c5c3_Err
800890 }
801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" focusable=\"false\"><defs><linearGradient id=\"")
891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" focusable=\"false\"><defs><linearGradient id=\"")
802892 if templ_7745c5c3_Err != nil {
803893 return templ_7745c5c3_Err
804894 }
805- var templ_7745c5c3_Var40 string
806- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.GradID)
895+ var templ_7745c5c3_Var46 string
896+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.GradID)
807897 if templ_7745c5c3_Err != nil {
808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 133, Col: 33}
898+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 139, Col: 33}
809899 }
810- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
900+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
811901 if templ_7745c5c3_Err != nil {
812902 return templ_7745c5c3_Err
813903 }
814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop class=\"pulse-stop-top\" offset=\"0\"></stop> <stop class=\"pulse-stop-bottom\" offset=\"1\"></stop></linearGradient></defs> <path class=\"pulse-grid\" d=\"M0 21.5 H600 M0 42.5 H600 M0 63.5 H600\" vector-effect=\"non-scaling-stroke\"></path> <path class=\"pulse-area\" d=\"")
904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop class=\"pulse-stop-top\" offset=\"0\"></stop> <stop class=\"pulse-stop-bottom\" offset=\"1\"></stop></linearGradient></defs> <path class=\"pulse-grid\" d=\"M0 21.5 H600 M0 42.5 H600 M0 63.5 H600\" vector-effect=\"non-scaling-stroke\"></path> <path class=\"pulse-area\" d=\"")
815905 if templ_7745c5c3_Err != nil {
816906 return templ_7745c5c3_Err
817907 }
818- var templ_7745c5c3_Var41 string
819- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Area)
908+ var templ_7745c5c3_Var47 string
909+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Area)
820910 if templ_7745c5c3_Err != nil {
821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 139, Col: 38}
911+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 38}
822912 }
823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
913+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
824914 if templ_7745c5c3_Err != nil {
825915 return templ_7745c5c3_Err
826916 }
827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" fill=\"")
917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" fill=\"")
828918 if templ_7745c5c3_Err != nil {
829919 return templ_7745c5c3_Err
830920 }
831- var templ_7745c5c3_Var42 string
832- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("url(#" + h.GradID + ")")
921+ var templ_7745c5c3_Var48 string
922+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue("url(#" + h.GradID + ")")
833923 if templ_7745c5c3_Err != nil {
834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 139, Col: 72}
924+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 72}
835925 }
836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
926+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
837927 if templ_7745c5c3_Err != nil {
838928 return templ_7745c5c3_Err
839929 }
840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"></path> <path class=\"pulse-line\" d=\"")
930+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"></path> <path class=\"pulse-line\" d=\"")
841931 if templ_7745c5c3_Err != nil {
842932 return templ_7745c5c3_Err
843933 }
844- var templ_7745c5c3_Var43 string
845- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Line)
934+ var templ_7745c5c3_Var49 string
935+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Line)
846936 if templ_7745c5c3_Err != nil {
847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 140, Col: 38}
937+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 146, Col: 38}
848938 }
849- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
939+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
850940 if templ_7745c5c3_Err != nil {
851941 return templ_7745c5c3_Err
852942 }
853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" vector-effect=\"non-scaling-stroke\"></path> <circle class=\"pulse-dot\" cx=\"")
943+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" vector-effect=\"non-scaling-stroke\"></path> <circle class=\"pulse-dot\" cx=\"")
854944 if templ_7745c5c3_Err != nil {
855945 return templ_7745c5c3_Err
856946 }
857- var templ_7745c5c3_Var44 string
858- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
947+ var templ_7745c5c3_Var50 string
948+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
859949 if templ_7745c5c3_Err != nil {
860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 141, Col: 37}
950+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 37}
861951 }
862- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
952+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
863953 if templ_7745c5c3_Err != nil {
864954 return templ_7745c5c3_Err
865955 }
866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" cy=\"")
956+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" cy=\"")
867957 if templ_7745c5c3_Err != nil {
868958 return templ_7745c5c3_Err
869959 }
870- var templ_7745c5c3_Var45 string
871- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
960+ var templ_7745c5c3_Var51 string
961+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
872962 if templ_7745c5c3_Err != nil {
873- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 141, Col: 48}
963+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 48}
874964 }
875- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
965+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
876966 if templ_7745c5c3_Err != nil {
877967 return templ_7745c5c3_Err
878968 }
879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" r=\"3.5\"></circle></svg></figure>")
969+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" r=\"3.5\"></circle></svg></figure>")
880970 if templ_7745c5c3_Err != nil {
881971 return templ_7745c5c3_Err
882972 }
@@ -901,66 +991,66 @@ func Outline(headings []Heading) templ.Component {
901991 }()
902992 }
903993 ctx = templ.InitializeContext(ctx)
904- templ_7745c5c3_Var46 := templ.GetChildren(ctx)
905- if templ_7745c5c3_Var46 == nil {
906- templ_7745c5c3_Var46 = templ.NopComponent
994+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
995+ if templ_7745c5c3_Var52 == nil {
996+ templ_7745c5c3_Var52 = templ.NopComponent
907997 }
908998 ctx = templ.ClearChildren(ctx)
909- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<nav class=\"outline\" aria-label=\"Outline\">")
999+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<nav class=\"outline\" aria-label=\"Outline\">")
9101000 if templ_7745c5c3_Err != nil {
9111001 return templ_7745c5c3_Err
9121002 }
9131003 for _, h := range headings {
914- var templ_7745c5c3_Var47 = []any{"l" + itoa(h.Level)}
915- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...)
1004+ var templ_7745c5c3_Var53 = []any{"l" + itoa(h.Level)}
1005+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var53...)
9161006 if templ_7745c5c3_Err != nil {
9171007 return templ_7745c5c3_Err
9181008 }
919- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<a class=\"")
1009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<a class=\"")
9201010 if templ_7745c5c3_Err != nil {
9211011 return templ_7745c5c3_Err
9221012 }
923- var templ_7745c5c3_Var48 string
924- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var47).String())
1013+ var templ_7745c5c3_Var54 string
1014+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var53).String())
9251015 if templ_7745c5c3_Err != nil {
9261016 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
9271017 }
928- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
1018+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
9291019 if templ_7745c5c3_Err != nil {
9301020 return templ_7745c5c3_Err
9311021 }
932- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" href=\"")
1022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" href=\"")
9331023 if templ_7745c5c3_Err != nil {
9341024 return templ_7745c5c3_Err
9351025 }
936- var templ_7745c5c3_Var49 templ.SafeURL
937- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
1026+ var templ_7745c5c3_Var55 templ.SafeURL
1027+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
9381028 if templ_7745c5c3_Err != nil {
939- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 68}
1029+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 156, Col: 68}
9401030 }
941- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1031+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
9421032 if templ_7745c5c3_Err != nil {
9431033 return templ_7745c5c3_Err
9441034 }
945- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\">")
1035+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\">")
9461036 if templ_7745c5c3_Err != nil {
9471037 return templ_7745c5c3_Err
9481038 }
949- var templ_7745c5c3_Var50 string
950- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
1039+ var templ_7745c5c3_Var56 string
1040+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
9511041 if templ_7745c5c3_Err != nil {
952- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 79}
1042+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 156, Col: 79}
9531043 }
954- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1044+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
9551045 if templ_7745c5c3_Err != nil {
9561046 return templ_7745c5c3_Err
9571047 }
958- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</a>")
1048+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</a>")
9591049 if templ_7745c5c3_Err != nil {
9601050 return templ_7745c5c3_Err
9611051 }
9621052 }
963- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</nav>")
1053+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</nav>")
9641054 if templ_7745c5c3_Err != nil {
9651055 return templ_7745c5c3_Err
9661056 }
@@ -969,7 +1059,7 @@ func Outline(headings []Heading) templ.Component {
9691059 }
9701060
9711061 // ReadmeCard is a README in a card whose header names the file.
972-func ReadmeCard(r Readme) templ.Component {
1062+func ReadmeCard(r Readme, docs ...DocTab) templ.Component {
9731063 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
9741064 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
9751065 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -985,33 +1075,103 @@ func ReadmeCard(r Readme) templ.Component {
9851075 }()
9861076 }
9871077 ctx = templ.InitializeContext(ctx)
988- templ_7745c5c3_Var51 := templ.GetChildren(ctx)
989- if templ_7745c5c3_Var51 == nil {
990- templ_7745c5c3_Var51 = templ.NopComponent
1078+ templ_7745c5c3_Var57 := templ.GetChildren(ctx)
1079+ if templ_7745c5c3_Var57 == nil {
1080+ templ_7745c5c3_Var57 = templ.NopComponent
9911081 }
9921082 ctx = templ.ClearChildren(ctx)
993- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
1083+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<section class=\"card readme\" aria-label=\"")
9941084 if templ_7745c5c3_Err != nil {
9951085 return templ_7745c5c3_Err
9961086 }
997- var templ_7745c5c3_Var52 string
998- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1087+ var templ_7745c5c3_Var58 string
1088+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
9991089 if templ_7745c5c3_Err != nil {
1000- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 160, Col: 11}
1090+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 49}
10011091 }
1002- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1092+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
10031093 if templ_7745c5c3_Err != nil {
10041094 return templ_7745c5c3_Err
10051095 }
1006- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</h2>")
1096+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">")
10071097 if templ_7745c5c3_Err != nil {
10081098 return templ_7745c5c3_Err
10091099 }
1100+ if len(docs) > 1 {
1101+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<nav class=\"card-h doc-tabs\" aria-label=\"Repository documents\">")
1102+ if templ_7745c5c3_Err != nil {
1103+ return templ_7745c5c3_Err
1104+ }
1105+ for _, doc := range docs {
1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<a href=\"")
1107+ if templ_7745c5c3_Err != nil {
1108+ return templ_7745c5c3_Err
1109+ }
1110+ var templ_7745c5c3_Var59 templ.SafeURL
1111+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(doc.Href))
1112+ if templ_7745c5c3_Err != nil {
1113+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 167, Col: 38}
1114+ }
1115+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1116+ if templ_7745c5c3_Err != nil {
1117+ return templ_7745c5c3_Err
1118+ }
1119+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"")
1120+ if templ_7745c5c3_Err != nil {
1121+ return templ_7745c5c3_Err
1122+ }
1123+ if doc.Current {
1124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " aria-current=\"page\"")
1125+ if templ_7745c5c3_Err != nil {
1126+ return templ_7745c5c3_Err
1127+ }
1128+ }
1129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, ">")
1130+ if templ_7745c5c3_Err != nil {
1131+ return templ_7745c5c3_Err
1132+ }
1133+ var templ_7745c5c3_Var60 string
1134+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Name)
1135+ if templ_7745c5c3_Err != nil {
1136+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 171, Col: 16}
1137+ }
1138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1139+ if templ_7745c5c3_Err != nil {
1140+ return templ_7745c5c3_Err
1141+ }
1142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</a>")
1143+ if templ_7745c5c3_Err != nil {
1144+ return templ_7745c5c3_Err
1145+ }
1146+ }
1147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</nav>")
1148+ if templ_7745c5c3_Err != nil {
1149+ return templ_7745c5c3_Err
1150+ }
1151+ } else {
1152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
1153+ if templ_7745c5c3_Err != nil {
1154+ return templ_7745c5c3_Err
1155+ }
1156+ var templ_7745c5c3_Var61 string
1157+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1158+ if templ_7745c5c3_Err != nil {
1159+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 177, Col: 12}
1160+ }
1161+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1162+ if templ_7745c5c3_Err != nil {
1163+ return templ_7745c5c3_Err
1164+ }
1165+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</h2>")
1166+ if templ_7745c5c3_Err != nil {
1167+ return templ_7745c5c3_Err
1168+ }
1169+ }
10101170 templ_7745c5c3_Err = ReadmeView(r).Render(ctx, templ_7745c5c3_Buffer)
10111171 if templ_7745c5c3_Err != nil {
10121172 return templ_7745c5c3_Err
10131173 }
1014- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</section>")
1174+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</section>")
10151175 if templ_7745c5c3_Err != nil {
10161176 return templ_7745c5c3_Err
10171177 }
@@ -1037,43 +1197,43 @@ func ReadmeView(r Readme) templ.Component {
10371197 }()
10381198 }
10391199 ctx = templ.InitializeContext(ctx)
1040- templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1041- if templ_7745c5c3_Var53 == nil {
1042- templ_7745c5c3_Var53 = templ.NopComponent
1200+ templ_7745c5c3_Var62 := templ.GetChildren(ctx)
1201+ if templ_7745c5c3_Var62 == nil {
1202+ templ_7745c5c3_Var62 = templ.NopComponent
10431203 }
10441204 ctx = templ.ClearChildren(ctx)
1045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<article class=\"prose\" aria-label=\"")
1205+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<article class=\"prose\" aria-label=\"")
10461206 if templ_7745c5c3_Err != nil {
10471207 return templ_7745c5c3_Err
10481208 }
1049- var templ_7745c5c3_Var54 string
1050- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
1209+ var templ_7745c5c3_Var63 string
1210+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
10511211 if templ_7745c5c3_Err != nil {
1052- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 169, Col: 43}
1212+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 187, Col: 43}
10531213 }
1054- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1214+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
10551215 if templ_7745c5c3_Err != nil {
10561216 return templ_7745c5c3_Err
10571217 }
1058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" hx-ignore>")
1218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" hx-ignore>")
10591219 if templ_7745c5c3_Err != nil {
10601220 return templ_7745c5c3_Err
10611221 }
10621222 if r.Note != "" {
1063- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<p class=\"dim\">")
1223+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">")
10641224 if templ_7745c5c3_Err != nil {
10651225 return templ_7745c5c3_Err
10661226 }
1067- var templ_7745c5c3_Var55 string
1068- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
1227+ var templ_7745c5c3_Var64 string
1228+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
10691229 if templ_7745c5c3_Err != nil {
1070- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 171, Col: 26}
1230+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 189, Col: 26}
10711231 }
1072- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1232+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
10731233 if templ_7745c5c3_Err != nil {
10741234 return templ_7745c5c3_Err
10751235 }
1076- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</p>")
1236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p>")
10771237 if templ_7745c5c3_Err != nil {
10781238 return templ_7745c5c3_Err
10791239 }
@@ -1083,7 +1243,7 @@ func ReadmeView(r Readme) templ.Component {
10831243 return templ_7745c5c3_Err
10841244 }
10851245 }
1086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</article>")
1246+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</article>")
10871247 if templ_7745c5c3_Err != nil {
10881248 return templ_7745c5c3_Err
10891249 }
@@ -1109,90 +1269,90 @@ func CommitRows(rows []CommitRow) templ.Component {
11091269 }()
11101270 }
11111271 ctx = templ.InitializeContext(ctx)
1112- templ_7745c5c3_Var56 := templ.GetChildren(ctx)
1113- if templ_7745c5c3_Var56 == nil {
1114- templ_7745c5c3_Var56 = templ.NopComponent
1272+ templ_7745c5c3_Var65 := templ.GetChildren(ctx)
1273+ if templ_7745c5c3_Var65 == nil {
1274+ templ_7745c5c3_Var65 = templ.NopComponent
11151275 }
11161276 ctx = templ.ClearChildren(ctx)
1117- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
1277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
11181278 if templ_7745c5c3_Err != nil {
11191279 return templ_7745c5c3_Err
11201280 }
11211281 for _, c := range rows {
1122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<tr")
1282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<tr")
11231283 if templ_7745c5c3_Err != nil {
11241284 return templ_7745c5c3_Err
11251285 }
11261286 if c.ID != "" {
1127- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " id=\"")
1287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " id=\"")
11281288 if templ_7745c5c3_Err != nil {
11291289 return templ_7745c5c3_Err
11301290 }
1131- var templ_7745c5c3_Var57 string
1132- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
1291+ var templ_7745c5c3_Var66 string
1292+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
11331293 if templ_7745c5c3_Err != nil {
1134- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 186, Col: 15}
1294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 204, Col: 15}
11351295 }
1136- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
1296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
11371297 if templ_7745c5c3_Err != nil {
11381298 return templ_7745c5c3_Err
11391299 }
1140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"")
1300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\"")
11411301 if templ_7745c5c3_Err != nil {
11421302 return templ_7745c5c3_Err
11431303 }
11441304 }
11451305 if c.Cursor {
1146- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " data-cursor")
1306+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, " data-cursor")
11471307 if templ_7745c5c3_Err != nil {
11481308 return templ_7745c5c3_Err
11491309 }
11501310 }
1151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "><td class=\"c-sha mute\">")
1311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "><td class=\"c-sha mute\">")
11521312 if templ_7745c5c3_Err != nil {
11531313 return templ_7745c5c3_Err
11541314 }
1155- var templ_7745c5c3_Var58 string
1156- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
1315+ var templ_7745c5c3_Var67 string
1316+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
11571317 if templ_7745c5c3_Err != nil {
1158- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 190, Col: 37}
1318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 37}
11591319 }
1160- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1320+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
11611321 if templ_7745c5c3_Err != nil {
11621322 return templ_7745c5c3_Err
11631323 }
1164- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</td><td class=\"c-subject\"><a href=\"")
1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</td><td class=\"c-subject\"><a href=\"")
11651325 if templ_7745c5c3_Err != nil {
11661326 return templ_7745c5c3_Err
11671327 }
1168- var templ_7745c5c3_Var59 templ.SafeURL
1169- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
1328+ var templ_7745c5c3_Var68 templ.SafeURL
1329+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
11701330 if templ_7745c5c3_Err != nil {
1171- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 192, Col: 38}
1331+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 210, Col: 38}
11721332 }
1173- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1333+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
11741334 if templ_7745c5c3_Err != nil {
11751335 return templ_7745c5c3_Err
11761336 }
1177- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">")
1337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
11781338 if templ_7745c5c3_Err != nil {
11791339 return templ_7745c5c3_Err
11801340 }
1181- var templ_7745c5c3_Var60 string
1182- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
1341+ var templ_7745c5c3_Var69 string
1342+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
11831343 if templ_7745c5c3_Err != nil {
1184- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 192, Col: 52}
1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 210, Col: 52}
11851345 }
1186- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
11871347 if templ_7745c5c3_Err != nil {
11881348 return templ_7745c5c3_Err
11891349 }
1190- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</a> ")
1350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</a> ")
11911351 if templ_7745c5c3_Err != nil {
11921352 return templ_7745c5c3_Err
11931353 }
11941354 if c.Merge {
1195- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<span class=\"pill\">merge</span> ")
1355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span class=\"pill\">merge</span> ")
11961356 if templ_7745c5c3_Err != nil {
11971357 return templ_7745c5c3_Err
11981358 }
@@ -1204,44 +1364,44 @@ func CommitRows(rows []CommitRow) templ.Component {
12041364 }
12051365 }
12061366 for _, b := range c.Branches {
1207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<span class=\"pill\">")
1367+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"pill\">")
12081368 if templ_7745c5c3_Err != nil {
12091369 return templ_7745c5c3_Err
12101370 }
1211- var templ_7745c5c3_Var61 string
1212- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(b)
1371+ var templ_7745c5c3_Var70 string
1372+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(b)
12131373 if templ_7745c5c3_Err != nil {
1214- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 200, Col: 29}
1374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 218, Col: 29}
12151375 }
1216- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1376+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
12171377 if templ_7745c5c3_Err != nil {
12181378 return templ_7745c5c3_Err
12191379 }
1220- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span> ")
1380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</span> ")
12211381 if templ_7745c5c3_Err != nil {
12221382 return templ_7745c5c3_Err
12231383 }
12241384 }
12251385 for _, t := range c.Tags {
1226- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
12271387 if templ_7745c5c3_Err != nil {
12281388 return templ_7745c5c3_Err
12291389 }
1230- var templ_7745c5c3_Var62 string
1231- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(t)
1390+ var templ_7745c5c3_Var71 string
1391+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(t)
12321392 if templ_7745c5c3_Err != nil {
1233- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 203, Col: 61}
1393+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 221, Col: 61}
12341394 }
1235- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1395+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
12361396 if templ_7745c5c3_Err != nil {
12371397 return templ_7745c5c3_Err
12381398 }
1239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</span>")
1399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</span>")
12401400 if templ_7745c5c3_Err != nil {
12411401 return templ_7745c5c3_Err
12421402 }
12431403 }
1244- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td><td class=\"c-ck\">")
1404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"c-ck\">")
12451405 if templ_7745c5c3_Err != nil {
12461406 return templ_7745c5c3_Err
12471407 }
@@ -1249,64 +1409,64 @@ func CommitRows(rows []CommitRow) templ.Component {
12491409 if templ_7745c5c3_Err != nil {
12501410 return templ_7745c5c3_Err
12511411 }
1252- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</td><td class=\"c-who dim\">")
1412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"c-who dim\">")
12531413 if templ_7745c5c3_Err != nil {
12541414 return templ_7745c5c3_Err
12551415 }
1256- var templ_7745c5c3_Var63 string
1257- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1416+ var templ_7745c5c3_Var72 string
1417+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
12581418 if templ_7745c5c3_Err != nil {
1259- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 207, Col: 37}
1419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 225, Col: 37}
12601420 }
1261- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1421+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
12621422 if templ_7745c5c3_Err != nil {
12631423 return templ_7745c5c3_Err
12641424 }
1265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
1425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
12661426 if templ_7745c5c3_Err != nil {
12671427 return templ_7745c5c3_Err
12681428 }
1269- var templ_7745c5c3_Var64 string
1270- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1429+ var templ_7745c5c3_Var73 string
1430+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
12711431 if templ_7745c5c3_Err != nil {
1272- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 60}
1432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 226, Col: 60}
12731433 }
1274- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1434+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
12751435 if templ_7745c5c3_Err != nil {
12761436 return templ_7745c5c3_Err
12771437 }
1278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" title=\"")
1438+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" title=\"")
12791439 if templ_7745c5c3_Err != nil {
12801440 return templ_7745c5c3_Err
12811441 }
1282- var templ_7745c5c3_Var65 string
1283- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1442+ var templ_7745c5c3_Var74 string
1443+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
12841444 if templ_7745c5c3_Err != nil {
1285- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 79}
1445+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 226, Col: 79}
12861446 }
1287- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
1447+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
12881448 if templ_7745c5c3_Err != nil {
12891449 return templ_7745c5c3_Err
12901450 }
1291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">")
1451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">")
12921452 if templ_7745c5c3_Err != nil {
12931453 return templ_7745c5c3_Err
12941454 }
1295- var templ_7745c5c3_Var66 string
1296- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1455+ var templ_7745c5c3_Var75 string
1456+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
12971457 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 90}
1458+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 226, Col: 90}
12991459 }
1300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1460+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
13011461 if templ_7745c5c3_Err != nil {
13021462 return templ_7745c5c3_Err
13031463 }
1304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</time></td></tr>")
1464+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</time></td></tr>")
13051465 if templ_7745c5c3_Err != nil {
13061466 return templ_7745c5c3_Err
13071467 }
13081468 }
1309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</tbody></table>")
1469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody></table>")
13101470 if templ_7745c5c3_Err != nil {
13111471 return templ_7745c5c3_Err
13121472 }
@@ -1332,75 +1492,75 @@ func signaturePill(b *SignatureBadge) templ.Component {
13321492 }()
13331493 }
13341494 ctx = templ.InitializeContext(ctx)
1335- templ_7745c5c3_Var67 := templ.GetChildren(ctx)
1336- if templ_7745c5c3_Var67 == nil {
1337- templ_7745c5c3_Var67 = templ.NopComponent
1495+ templ_7745c5c3_Var76 := templ.GetChildren(ctx)
1496+ if templ_7745c5c3_Var76 == nil {
1497+ templ_7745c5c3_Var76 = templ.NopComponent
13381498 }
13391499 ctx = templ.ClearChildren(ctx)
1340- var templ_7745c5c3_Var68 = []any{"pill sig", b.Class}
1341- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...)
1500+ var templ_7745c5c3_Var77 = []any{"pill sig", b.Class}
1501+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)
13421502 if templ_7745c5c3_Err != nil {
13431503 return templ_7745c5c3_Err
13441504 }
1345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<span class=\"")
1505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<span class=\"")
13461506 if templ_7745c5c3_Err != nil {
13471507 return templ_7745c5c3_Err
13481508 }
1349- var templ_7745c5c3_Var69 string
1350- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var68).String())
1509+ var templ_7745c5c3_Var78 string
1510+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())
13511511 if templ_7745c5c3_Err != nil {
13521512 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
13531513 }
1354- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1514+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
13551515 if templ_7745c5c3_Err != nil {
13561516 return templ_7745c5c3_Err
13571517 }
1358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" title=\"")
1518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\" title=\"")
13591519 if templ_7745c5c3_Err != nil {
13601520 return templ_7745c5c3_Err
13611521 }
1362- var templ_7745c5c3_Var70 string
1363- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(b.Title)
1522+ var templ_7745c5c3_Var79 string
1523+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(b.Title)
13641524 if templ_7745c5c3_Err != nil {
1365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 218, Col: 52}
1525+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 236, Col: 52}
13661526 }
1367- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
1527+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
13681528 if templ_7745c5c3_Err != nil {
13691529 return templ_7745c5c3_Err
13701530 }
1371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">")
1531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">")
13721532 if templ_7745c5c3_Err != nil {
13731533 return templ_7745c5c3_Err
13741534 }
13751535 if b.Proto != "" {
1376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<span class=\"sig-proto\">")
1536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<span class=\"sig-proto\">")
13771537 if templ_7745c5c3_Err != nil {
13781538 return templ_7745c5c3_Err
13791539 }
1380- var templ_7745c5c3_Var71 string
1381- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(b.Proto)
1540+ var templ_7745c5c3_Var80 string
1541+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(b.Proto)
13821542 if templ_7745c5c3_Err != nil {
1383- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 220, Col: 36}
1543+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 238, Col: 36}
13841544 }
1385- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1545+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
13861546 if templ_7745c5c3_Err != nil {
13871547 return templ_7745c5c3_Err
13881548 }
1389- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</span> ")
1549+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</span> ")
13901550 if templ_7745c5c3_Err != nil {
13911551 return templ_7745c5c3_Err
13921552 }
13931553 }
1394- var templ_7745c5c3_Var72 string
1395- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
1554+ var templ_7745c5c3_Var81 string
1555+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
13961556 if templ_7745c5c3_Err != nil {
1397- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 222, Col: 11}
1557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 240, Col: 11}
13981558 }
1399- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
1559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
14001560 if templ_7745c5c3_Err != nil {
14011561 return templ_7745c5c3_Err
14021562 }
1403- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</span>")
1563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</span>")
14041564 if templ_7745c5c3_Err != nil {
14051565 return templ_7745c5c3_Err
14061566 }
@@ -231,89 +231,179 @@ func HomeRowView(r HomeRow) templ.Component {
231 if templ_7745c5c3_Err != nil {231 if templ_7745c5c3_Err != nil {
232 return templ_7745c5c3_Err232 return templ_7745c5c3_Err
233 }233 }
234+ if rel := r.Release; rel != nil {
235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a class=\"pill repo-release\" href=\"")
236+ if templ_7745c5c3_Err != nil {
237+ return templ_7745c5c3_Err
238+ }
239+ var templ_7745c5c3_Var11 templ.SafeURL
240+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(rel.Href))
241+ if templ_7745c5c3_Err != nil {
242+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 45, Col: 64}
243+ }
244+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
245+ if templ_7745c5c3_Err != nil {
246+ return templ_7745c5c3_Err
247+ }
248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"")
249+ if templ_7745c5c3_Err != nil {
250+ return templ_7745c5c3_Err
251+ }
252+ var templ_7745c5c3_Var12 string
253+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Latest release: " + rel.Tag)
254+ if templ_7745c5c3_Err != nil {
255+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 45, Col: 103}
256+ }
257+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
258+ if templ_7745c5c3_Err != nil {
259+ return templ_7745c5c3_Err
260+ }
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
262+ if templ_7745c5c3_Err != nil {
263+ return templ_7745c5c3_Err
264+ }
265+ var templ_7745c5c3_Var13 string
266+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Tag)
267+ if templ_7745c5c3_Err != nil {
268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 45, Col: 115}
269+ }
270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
271+ if templ_7745c5c3_Err != nil {
272+ return templ_7745c5c3_Err
273+ }
274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a> ")
275+ if templ_7745c5c3_Err != nil {
276+ return templ_7745c5c3_Err
277+ }
278+ }
234 if r.Private {279 if r.Private {
235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"pill\">private</span> ")280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"pill\">private</span> ")
281+ if templ_7745c5c3_Err != nil {
282+ return templ_7745c5c3_Err
283+ }
284+ }
285+ if lic := r.License; lic != nil {
286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a class=\"pill repo-license\" href=\"")
287+ if templ_7745c5c3_Err != nil {
288+ return templ_7745c5c3_Err
289+ }
290+ var templ_7745c5c3_Var14 templ.SafeURL
291+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(lic.Href))
292+ if templ_7745c5c3_Err != nil {
293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 64}
294+ }
295+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
296+ if templ_7745c5c3_Err != nil {
297+ return templ_7745c5c3_Err
298+ }
299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" title=\"")
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ var templ_7745c5c3_Var15 string
304+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("License: " + lic.ID)
305+ if templ_7745c5c3_Err != nil {
306+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 95}
307+ }
308+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
309+ if templ_7745c5c3_Err != nil {
310+ return templ_7745c5c3_Err
311+ }
312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
313+ if templ_7745c5c3_Err != nil {
314+ return templ_7745c5c3_Err
315+ }
316+ var templ_7745c5c3_Var16 string
317+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lic.ID)
318+ if templ_7745c5c3_Err != nil {
319+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 106}
320+ }
321+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
322+ if templ_7745c5c3_Err != nil {
323+ return templ_7745c5c3_Err
324+ }
325+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a> ")
236 if templ_7745c5c3_Err != nil {326 if templ_7745c5c3_Err != nil {
237 return templ_7745c5c3_Err327 return templ_7745c5c3_Err
238 }328 }
239 }329 }
240 if r.Fresh {330 if r.Fresh {
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"pill new\">new activity</span>")331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"repo-activity\" title=\"new activity\"><span class=\"ico i-refresh-cw\" aria-hidden=\"true\"></span><span class=\"sr\">new activity</span></span>")
242 if templ_7745c5c3_Err != nil {332 if templ_7745c5c3_Err != nil {
243 return templ_7745c5c3_Err333 return templ_7745c5c3_Err
244 }334 }
245 }335 }
246- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h2>")336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</h2>")
247 if templ_7745c5c3_Err != nil {337 if templ_7745c5c3_Err != nil {
248 return templ_7745c5c3_Err338 return templ_7745c5c3_Err
249 }339 }
250 if r.Desc != "" {340 if r.Desc != "" {
251- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p class=\"repo-desc\">")341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<p class=\"repo-desc\">")
252 if templ_7745c5c3_Err != nil {342 if templ_7745c5c3_Err != nil {
253 return templ_7745c5c3_Err343 return templ_7745c5c3_Err
254 }344 }
255- var templ_7745c5c3_Var11 string345+ var templ_7745c5c3_Var17 string
256- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)346+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
257 if templ_7745c5c3_Err != nil {347 if templ_7745c5c3_Err != nil {
258- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 52, Col: 33}348+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 33}
259 }349 }
260- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))350+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
261 if templ_7745c5c3_Err != nil {351 if templ_7745c5c3_Err != nil {
262 return templ_7745c5c3_Err352 return templ_7745c5c3_Err
263 }353 }
264- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p>")354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p>")
265 if templ_7745c5c3_Err != nil {355 if templ_7745c5c3_Err != nil {
266 return templ_7745c5c3_Err356 return templ_7745c5c3_Err
267 }357 }
268 } else {358 } else {
269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"repo-desc mute\">no description</p>")359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<p class=\"repo-desc mute\">no description</p>")
270 if templ_7745c5c3_Err != nil {360 if templ_7745c5c3_Err != nil {
271 return templ_7745c5c3_Err361 return templ_7745c5c3_Err
272 }362 }
273 }363 }
274 if len(r.Topics) > 0 {364 if len(r.Topics) > 0 {
275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<ul class=\"repo-topics\" aria-label=\"")365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<ul class=\"repo-topics\" aria-label=\"")
276 if templ_7745c5c3_Err != nil {366 if templ_7745c5c3_Err != nil {
277 return templ_7745c5c3_Err367 return templ_7745c5c3_Err
278 }368 }
279- var templ_7745c5c3_Var12 string369+ var templ_7745c5c3_Var18 string
280- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Topics of " + r.Name)370+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Topics of " + r.Name)
281 if templ_7745c5c3_Err != nil {371 if templ_7745c5c3_Err != nil {
282- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 62}372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 62}
283 }373 }
284- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
285 if templ_7745c5c3_Err != nil {375 if templ_7745c5c3_Err != nil {
286 return templ_7745c5c3_Err376 return templ_7745c5c3_Err
287 }377 }
288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-fit>")378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" data-fit>")
289 if templ_7745c5c3_Err != nil {379 if templ_7745c5c3_Err != nil {
290 return templ_7745c5c3_Err380 return templ_7745c5c3_Err
291 }381 }
292 for _, t := range r.Topics {382 for _, t := range r.Topics {
293- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<li>")383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<li>")
294 if templ_7745c5c3_Err != nil {384 if templ_7745c5c3_Err != nil {
295 return templ_7745c5c3_Err385 return templ_7745c5c3_Err
296 }386 }
297- var templ_7745c5c3_Var13 string387+ var templ_7745c5c3_Var19 string
298- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t)388+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(t)
299 if templ_7745c5c3_Err != nil {389 if templ_7745c5c3_Err != nil {
300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 59, Col: 13}390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 13}
301 }391 }
302- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
303 if templ_7745c5c3_Err != nil {393 if templ_7745c5c3_Err != nil {
304 return templ_7745c5c3_Err394 return templ_7745c5c3_Err
305 }395 }
306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</li>")396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</li>")
307 if templ_7745c5c3_Err != nil {397 if templ_7745c5c3_Err != nil {
308 return templ_7745c5c3_Err398 return templ_7745c5c3_Err
309 }399 }
310 }400 }
311- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul>")401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</ul>")
312 if templ_7745c5c3_Err != nil {402 if templ_7745c5c3_Err != nil {
313 return templ_7745c5c3_Err403 return templ_7745c5c3_Err
314 }404 }
315 } else {405 } else {
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"repo-topics\" aria-hidden=\"true\"></div>")406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"repo-topics\" aria-hidden=\"true\"></div>")
317 if templ_7745c5c3_Err != nil {407 if templ_7745c5c3_Err != nil {
318 return templ_7745c5c3_Err408 return templ_7745c5c3_Err
319 }409 }
@@ -324,425 +414,425 @@ func HomeRowView(r HomeRow) templ.Component {
324 return templ_7745c5c3_Err414 return templ_7745c5c3_Err
325 }415 }
326 } else {416 } else {
327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"repo-pulse\" aria-hidden=\"true\"></div>")417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"repo-pulse\" aria-hidden=\"true\"></div>")
328 if templ_7745c5c3_Err != nil {418 if templ_7745c5c3_Err != nil {
329 return templ_7745c5c3_Err419 return templ_7745c5c3_Err
330 }420 }
331 }421 }
332- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"repo-head\"><dl class=\"repo-stats\" aria-label=\"")422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div><div class=\"repo-head\"><dl class=\"repo-stats\" aria-label=\"")
333 if templ_7745c5c3_Err != nil {423 if templ_7745c5c3_Err != nil {
334 return templ_7745c5c3_Err424 return templ_7745c5c3_Err
335 }425 }
336- var templ_7745c5c3_Var14 string426+ var templ_7745c5c3_Var20 string
337- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)427+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
338 if templ_7745c5c3_Err != nil {428 if templ_7745c5c3_Err != nil {
339- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 72, Col: 61}429+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 78, Col: 61}
340 }430 }
341- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)431+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
342 if templ_7745c5c3_Err != nil {432 if templ_7745c5c3_Err != nil {
343 return templ_7745c5c3_Err433 return templ_7745c5c3_Err
344 }434 }
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
346 if templ_7745c5c3_Err != nil {436 if templ_7745c5c3_Err != nil {
347 return templ_7745c5c3_Err437 return templ_7745c5c3_Err
348 }438 }
349 if s := r.Stats; s != nil {439 if s := r.Stats; s != nil {
350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div><dt>commits</dt><dd>")440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div><dt>commits</dt><dd>")
351 if templ_7745c5c3_Err != nil {441 if templ_7745c5c3_Err != nil {
352 return templ_7745c5c3_Err442 return templ_7745c5c3_Err
353 }443 }
354- var templ_7745c5c3_Var15 string444+ var templ_7745c5c3_Var21 string
355- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)445+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
356 if templ_7745c5c3_Err != nil {446 if templ_7745c5c3_Err != nil {
357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 74, Col: 41}447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 41}
358 }448 }
359- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
360 if templ_7745c5c3_Err != nil {450 if templ_7745c5c3_Err != nil {
361 return templ_7745c5c3_Err451 return templ_7745c5c3_Err
362 }452 }
363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</dd></div><div><dt>last 30 days</dt><dd>")453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</dd></div><div><dt>last 30 days</dt><dd>")
364 if templ_7745c5c3_Err != nil {454 if templ_7745c5c3_Err != nil {
365 return templ_7745c5c3_Err455 return templ_7745c5c3_Err
366 }456 }
367- var templ_7745c5c3_Var16 string457+ var templ_7745c5c3_Var22 string
368- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)458+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
369 if templ_7745c5c3_Err != nil {459 if templ_7745c5c3_Err != nil {
370- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 75, Col: 45}460+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 45}
371 }461 }
372- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))462+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
373 if templ_7745c5c3_Err != nil {463 if templ_7745c5c3_Err != nil {
374 return templ_7745c5c3_Err464 return templ_7745c5c3_Err
375 }465 }
376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dd></div>")466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</dd></div>")
377 if templ_7745c5c3_Err != nil {467 if templ_7745c5c3_Err != nil {
378 return templ_7745c5c3_Err468 return templ_7745c5c3_Err
379 }469 }
380 }470 }
381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"rs-active\"><dt>last activity</dt>")471+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"rs-active\"><dt>last activity</dt>")
382 if templ_7745c5c3_Err != nil {472 if templ_7745c5c3_Err != nil {
383 return templ_7745c5c3_Err473 return templ_7745c5c3_Err
384 }474 }
385 if r.ActiveAt != "" {475 if r.ActiveAt != "" {
386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<dd><time datetime=\"")476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<dd><time datetime=\"")
387 if templ_7745c5c3_Err != nil {477 if templ_7745c5c3_Err != nil {
388 return templ_7745c5c3_Err478 return templ_7745c5c3_Err
389 }479 }
390- var templ_7745c5c3_Var17 string480+ var templ_7745c5c3_Var23 string
391- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)481+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
392 if templ_7745c5c3_Err != nil {482 if templ_7745c5c3_Err != nil {
393- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 37}483+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 37}
394 }484 }
395- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)485+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
396 if templ_7745c5c3_Err != nil {486 if templ_7745c5c3_Err != nil {
397 return templ_7745c5c3_Err487 return templ_7745c5c3_Err
398 }488 }
399- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" title=\"")489+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" title=\"")
400 if templ_7745c5c3_Err != nil {490 if templ_7745c5c3_Err != nil {
401 return templ_7745c5c3_Err491 return templ_7745c5c3_Err
402 }492 }
403- var templ_7745c5c3_Var18 string493+ var templ_7745c5c3_Var24 string
404- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)494+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
405 if templ_7745c5c3_Err != nil {495 if templ_7745c5c3_Err != nil {
406- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 58}496+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 58}
407 }497 }
408- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)498+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
409 if templ_7745c5c3_Err != nil {499 if templ_7745c5c3_Err != nil {
410 return templ_7745c5c3_Err500 return templ_7745c5c3_Err
411 }501 }
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")502+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
413 if templ_7745c5c3_Err != nil {503 if templ_7745c5c3_Err != nil {
414 return templ_7745c5c3_Err504 return templ_7745c5c3_Err
415 }505 }
416- var templ_7745c5c3_Var19 string506+ var templ_7745c5c3_Var25 string
417- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)507+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
418 if templ_7745c5c3_Err != nil {508 if templ_7745c5c3_Err != nil {
419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 71}509+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 71}
420 }510 }
421- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))511+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
422 if templ_7745c5c3_Err != nil {512 if templ_7745c5c3_Err != nil {
423 return templ_7745c5c3_Err513 return templ_7745c5c3_Err
424 }514 }
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</time></dd>")515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</time></dd>")
426 if templ_7745c5c3_Err != nil {516 if templ_7745c5c3_Err != nil {
427 return templ_7745c5c3_Err517 return templ_7745c5c3_Err
428 }518 }
429 } else {519 } else {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<dd class=\"mute\">never</dd>")520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<dd class=\"mute\">never</dd>")
431 if templ_7745c5c3_Err != nil {521 if templ_7745c5c3_Err != nil {
432 return templ_7745c5c3_Err522 return templ_7745c5c3_Err
433 }523 }
434 }524 }
435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")525+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
436 if templ_7745c5c3_Err != nil {526 if templ_7745c5c3_Err != nil {
437 return templ_7745c5c3_Err527 return templ_7745c5c3_Err
438 }528 }
439 if s := r.Stats; s != nil && s.Tag != "" {529 if s := r.Stats; s != nil && s.Tag != "" {
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"rs-tag\"><dt>last tag</dt>")530+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"rs-tag\"><dt>last tag</dt>")
441 if templ_7745c5c3_Err != nil {531 if templ_7745c5c3_Err != nil {
442 return templ_7745c5c3_Err532 return templ_7745c5c3_Err
443 }533 }
444 if s.TagHref != "" {534 if s.TagHref != "" {
445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<dd><a href=\"")535+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<dd><a href=\"")
446 if templ_7745c5c3_Err != nil {536 if templ_7745c5c3_Err != nil {
447 return templ_7745c5c3_Err537 return templ_7745c5c3_Err
448 }538 }
449- var templ_7745c5c3_Var20 templ.SafeURL539+ var templ_7745c5c3_Var26 templ.SafeURL
450- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))540+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
451 if templ_7745c5c3_Err != nil {541 if templ_7745c5c3_Err != nil {
452- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 45}542+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 45}
453 }543 }
454- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))544+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
455 if templ_7745c5c3_Err != nil {545 if templ_7745c5c3_Err != nil {
456 return templ_7745c5c3_Err546 return templ_7745c5c3_Err
457 }547 }
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")548+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" title=\"")
459 if templ_7745c5c3_Err != nil {549 if templ_7745c5c3_Err != nil {
460 return templ_7745c5c3_Err550 return templ_7745c5c3_Err
461 }551 }
462- var templ_7745c5c3_Var21 string552+ var templ_7745c5c3_Var27 string
463- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)553+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
464 if templ_7745c5c3_Err != nil {554 if templ_7745c5c3_Err != nil {
465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 87}555+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 87}
466 }556 }
467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)557+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
468 if templ_7745c5c3_Err != nil {558 if templ_7745c5c3_Err != nil {
469 return templ_7745c5c3_Err559 return templ_7745c5c3_Err
470 }560 }
471- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">")
472 if templ_7745c5c3_Err != nil {562 if templ_7745c5c3_Err != nil {
473 return templ_7745c5c3_Err563 return templ_7745c5c3_Err
474 }564 }
475- var templ_7745c5c3_Var22 string565+ var templ_7745c5c3_Var28 string
476- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)566+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
477 if templ_7745c5c3_Err != nil {567 if templ_7745c5c3_Err != nil {
478- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 97}568+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 97}
479 }569 }
480- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))570+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
481 if templ_7745c5c3_Err != nil {571 if templ_7745c5c3_Err != nil {
482 return templ_7745c5c3_Err572 return templ_7745c5c3_Err
483 }573 }
484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</a></dd>")574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</a></dd>")
485 if templ_7745c5c3_Err != nil {575 if templ_7745c5c3_Err != nil {
486 return templ_7745c5c3_Err576 return templ_7745c5c3_Err
487 }577 }
488 } else {578 } else {
489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<dd title=\"")579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<dd title=\"")
490 if templ_7745c5c3_Err != nil {580 if templ_7745c5c3_Err != nil {
491 return templ_7745c5c3_Err581 return templ_7745c5c3_Err
492 }582 }
493- var templ_7745c5c3_Var23 string583+ var templ_7745c5c3_Var29 string
494- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)584+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
495 if templ_7745c5c3_Err != nil {585 if templ_7745c5c3_Err != nil {
496- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 50}586+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 50}
497 }587 }
498- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)588+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
499 if templ_7745c5c3_Err != nil {589 if templ_7745c5c3_Err != nil {
500 return templ_7745c5c3_Err590 return templ_7745c5c3_Err
501 }591 }
502- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")592+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
503 if templ_7745c5c3_Err != nil {593 if templ_7745c5c3_Err != nil {
504 return templ_7745c5c3_Err594 return templ_7745c5c3_Err
505 }595 }
506- var templ_7745c5c3_Var24 string596+ var templ_7745c5c3_Var30 string
507- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)597+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
508 if templ_7745c5c3_Err != nil {598 if templ_7745c5c3_Err != nil {
509- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 60}599+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 60}
510 }600 }
511- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))601+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
512 if templ_7745c5c3_Err != nil {602 if templ_7745c5c3_Err != nil {
513 return templ_7745c5c3_Err603 return templ_7745c5c3_Err
514 }604 }
515- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</dd>")605+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</dd>")
516 if templ_7745c5c3_Err != nil {606 if templ_7745c5c3_Err != nil {
517 return templ_7745c5c3_Err607 return templ_7745c5c3_Err
518 }608 }
519 }609 }
520- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")
521 if templ_7745c5c3_Err != nil {611 if templ_7745c5c3_Err != nil {
522 return templ_7745c5c3_Err612 return templ_7745c5c3_Err
523 }613 }
524 }614 }
525- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</dl>")615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</dl>")
526 if templ_7745c5c3_Err != nil {616 if templ_7745c5c3_Err != nil {
527 return templ_7745c5c3_Err617 return templ_7745c5c3_Err
528 }618 }
529 if r.CI != nil {619 if r.CI != nil {
530- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a class=\"repo-ci\" href=\"")620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a class=\"repo-ci\" href=\"")
531 if templ_7745c5c3_Err != nil {621 if templ_7745c5c3_Err != nil {
532 return templ_7745c5c3_Err622 return templ_7745c5c3_Err
533 }623 }
534- var templ_7745c5c3_Var25 templ.SafeURL624+ var templ_7745c5c3_Var31 templ.SafeURL
535- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))625+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
536 if templ_7745c5c3_Err != nil {626 if templ_7745c5c3_Err != nil {
537- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 54}627+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 54}
538 }628 }
539- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))629+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
540 if templ_7745c5c3_Err != nil {630 if templ_7745c5c3_Err != nil {
541 return templ_7745c5c3_Err631 return templ_7745c5c3_Err
542 }632 }
543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" title=\"")633+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" title=\"")
544 if templ_7745c5c3_Err != nil {634 if templ_7745c5c3_Err != nil {
545 return templ_7745c5c3_Err635 return templ_7745c5c3_Err
546 }636 }
547- var templ_7745c5c3_Var26 string637+ var templ_7745c5c3_Var32 string
548- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)638+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Label + " · " + r.CI.Title)
549 if templ_7745c5c3_Err != nil {639 if templ_7745c5c3_Err != nil {
550- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 75}640+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 97}
551 }641 }
552- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)642+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
553 if templ_7745c5c3_Err != nil {643 if templ_7745c5c3_Err != nil {
554 return templ_7745c5c3_Err644 return templ_7745c5c3_Err
555 }645 }
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")646+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
557 if templ_7745c5c3_Err != nil {647 if templ_7745c5c3_Err != nil {
558 return templ_7745c5c3_Err648 return templ_7745c5c3_Err
559 }649 }
560- var templ_7745c5c3_Var27 = []any{"ck", "ck-" + r.CI.Kind}650+ var templ_7745c5c3_Var33 = []any{"ck", "ck-" + r.CI.Kind}
561- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...)651+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...)
562 if templ_7745c5c3_Err != nil {652 if templ_7745c5c3_Err != nil {
563 return templ_7745c5c3_Err653 return templ_7745c5c3_Err
564 }654 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"")655+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<span class=\"")
566 if templ_7745c5c3_Err != nil {656 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err657 return templ_7745c5c3_Err
568 }658 }
569- var templ_7745c5c3_Var28 string659+ var templ_7745c5c3_Var34 string
570- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var27).String())660+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var33).String())
571 if templ_7745c5c3_Err != nil {661 if templ_7745c5c3_Err != nil {
572 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}662 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
573 }663 }
574- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)664+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
575 if templ_7745c5c3_Err != nil {665 if templ_7745c5c3_Err != nil {
576 return templ_7745c5c3_Err666 return templ_7745c5c3_Err
577 }667 }
578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span><span class=\"ci-label\">")668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" aria-hidden=\"true\"></span> <span class=\"sr\">")
579 if templ_7745c5c3_Err != nil {669 if templ_7745c5c3_Err != nil {
580 return templ_7745c5c3_Err670 return templ_7745c5c3_Err
581 }671 }
582- var templ_7745c5c3_Var29 string672+ var templ_7745c5c3_Var35 string
583- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)673+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs("CI: " + r.CI.Label)
584 if templ_7745c5c3_Err != nil {674 if templ_7745c5c3_Err != nil {
585- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 99, Col: 68}675+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 105, Col: 43}
586 }676 }
587- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))677+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
588 if templ_7745c5c3_Err != nil {678 if templ_7745c5c3_Err != nil {
589 return templ_7745c5c3_Err679 return templ_7745c5c3_Err
590 }680 }
591- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span></a>")681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></a>")
592 if templ_7745c5c3_Err != nil {682 if templ_7745c5c3_Err != nil {
593 return templ_7745c5c3_Err683 return templ_7745c5c3_Err
594 }684 }
595 }685 }
596- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")686+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
597 if templ_7745c5c3_Err != nil {687 if templ_7745c5c3_Err != nil {
598 return templ_7745c5c3_Err688 return templ_7745c5c3_Err
599 }689 }
600 if len(r.Commits) > 0 {690 if len(r.Commits) > 0 {
601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<ul class=\"repo-commits\"")691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<ul class=\"repo-commits\"")
602 if templ_7745c5c3_Err != nil {692 if templ_7745c5c3_Err != nil {
603 return templ_7745c5c3_Err693 return templ_7745c5c3_Err
604 }694 }
605 if len(r.Commits) == homeRecent {695 if len(r.Commits) == homeRecent {
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " data-full")696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " data-full")
607 if templ_7745c5c3_Err != nil {697 if templ_7745c5c3_Err != nil {
608 return templ_7745c5c3_Err698 return templ_7745c5c3_Err
609 }699 }
610 }700 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " aria-label=\"")701+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " aria-label=\"")
612 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
613 return templ_7745c5c3_Err703 return templ_7745c5c3_Err
614 }704 }
615- var templ_7745c5c3_Var30 string705+ var templ_7745c5c3_Var36 string
616- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)706+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
617 if templ_7745c5c3_Err != nil {707 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 107, Col: 46}708+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 46}
619 }709 }
620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)710+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
621 if templ_7745c5c3_Err != nil {711 if templ_7745c5c3_Err != nil {
622 return templ_7745c5c3_Err712 return templ_7745c5c3_Err
623 }713 }
624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
625 if templ_7745c5c3_Err != nil {715 if templ_7745c5c3_Err != nil {
626 return templ_7745c5c3_Err716 return templ_7745c5c3_Err
627 }717 }
628 for _, c := range r.Commits {718 for _, c := range r.Commits {
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<li><a class=\"rc-sha\" href=\"")719+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<li><a class=\"rc-sha\" href=\"")
630 if templ_7745c5c3_Err != nil {720 if templ_7745c5c3_Err != nil {
631 return templ_7745c5c3_Err721 return templ_7745c5c3_Err
632 }722 }
633- var templ_7745c5c3_Var31 templ.SafeURL723+ var templ_7745c5c3_Var37 templ.SafeURL
634- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))724+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
635 if templ_7745c5c3_Err != nil {725 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 111, Col: 53}726+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 53}
637 }727 }
638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))728+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
639 if templ_7745c5c3_Err != nil {729 if templ_7745c5c3_Err != nil {
640 return templ_7745c5c3_Err730 return templ_7745c5c3_Err
641 }731 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">")732+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\">")
643 if templ_7745c5c3_Err != nil {733 if templ_7745c5c3_Err != nil {
644 return templ_7745c5c3_Err734 return templ_7745c5c3_Err
645 }735 }
646- var templ_7745c5c3_Var32 string736+ var templ_7745c5c3_Var38 string
647- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)737+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
648 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 111, Col: 65}739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 117, Col: 65}
650 }740 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))741+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
652 if templ_7745c5c3_Err != nil {742 if templ_7745c5c3_Err != nil {
653 return templ_7745c5c3_Err743 return templ_7745c5c3_Err
654 }744 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</a> <span class=\"rc-subject\" title=\"")745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</a> <span class=\"rc-subject\" title=\"")
656 if templ_7745c5c3_Err != nil {746 if templ_7745c5c3_Err != nil {
657 return templ_7745c5c3_Err747 return templ_7745c5c3_Err
658 }748 }
659- var templ_7745c5c3_Var33 string749+ var templ_7745c5c3_Var39 string
660- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)750+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
661 if templ_7745c5c3_Err != nil {751 if templ_7745c5c3_Err != nil {
662- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 112, Col: 48}752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 48}
663 }753 }
664- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)754+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
665 if templ_7745c5c3_Err != nil {755 if templ_7745c5c3_Err != nil {
666 return templ_7745c5c3_Err756 return templ_7745c5c3_Err
667 }757 }
668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">")
669 if templ_7745c5c3_Err != nil {759 if templ_7745c5c3_Err != nil {
670 return templ_7745c5c3_Err760 return templ_7745c5c3_Err
671 }761 }
672- var templ_7745c5c3_Var34 string762+ var templ_7745c5c3_Var40 string
673- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)763+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
674 if templ_7745c5c3_Err != nil {764 if templ_7745c5c3_Err != nil {
675- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 112, Col: 62}765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 62}
676 }766 }
677- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
678 if templ_7745c5c3_Err != nil {768 if templ_7745c5c3_Err != nil {
679 return templ_7745c5c3_Err769 return templ_7745c5c3_Err
680 }770 }
681- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span> ")771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> ")
682 if templ_7745c5c3_Err != nil {772 if templ_7745c5c3_Err != nil {
683 return templ_7745c5c3_Err773 return templ_7745c5c3_Err
684 }774 }
685 if c.WhenAt != "" {775 if c.WhenAt != "" {
686- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<time class=\"dim\" datetime=\"")776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<time class=\"dim\" datetime=\"")
687 if templ_7745c5c3_Err != nil {777 if templ_7745c5c3_Err != nil {
688 return templ_7745c5c3_Err778 return templ_7745c5c3_Err
689 }779 }
690- var templ_7745c5c3_Var35 string780+ var templ_7745c5c3_Var41 string
691- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)781+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
692 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
693- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 114, Col: 44}783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 120, Col: 44}
694 }784 }
695- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
696 if templ_7745c5c3_Err != nil {786 if templ_7745c5c3_Err != nil {
697 return templ_7745c5c3_Err787 return templ_7745c5c3_Err
698 }788 }
699- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" title=\"")789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" title=\"")
700 if templ_7745c5c3_Err != nil {790 if templ_7745c5c3_Err != nil {
701 return templ_7745c5c3_Err791 return templ_7745c5c3_Err
702 }792 }
703- var templ_7745c5c3_Var36 string793+ var templ_7745c5c3_Var42 string
704- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)794+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
705 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
706- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 114, Col: 63}796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 120, Col: 63}
707 }797 }
708- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
709 if templ_7745c5c3_Err != nil {799 if templ_7745c5c3_Err != nil {
710 return templ_7745c5c3_Err800 return templ_7745c5c3_Err
711 }801 }
712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\">")
713 if templ_7745c5c3_Err != nil {803 if templ_7745c5c3_Err != nil {
714 return templ_7745c5c3_Err804 return templ_7745c5c3_Err
715 }805 }
716- var templ_7745c5c3_Var37 string806+ var templ_7745c5c3_Var43 string
717- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)807+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
718 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
719- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 114, Col: 74}809+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 120, Col: 74}
720 }810 }
721- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))811+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
722 if templ_7745c5c3_Err != nil {812 if templ_7745c5c3_Err != nil {
723 return templ_7745c5c3_Err813 return templ_7745c5c3_Err
724 }814 }
725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</time>")815+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</time>")
726 if templ_7745c5c3_Err != nil {816 if templ_7745c5c3_Err != nil {
727 return templ_7745c5c3_Err817 return templ_7745c5c3_Err
728 }818 }
729 }819 }
730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</li>")820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</li>")
731 if templ_7745c5c3_Err != nil {821 if templ_7745c5c3_Err != nil {
732 return templ_7745c5c3_Err822 return templ_7745c5c3_Err
733 }823 }
734 }824 }
735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</ul>")825+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</ul>")
736 if templ_7745c5c3_Err != nil {826 if templ_7745c5c3_Err != nil {
737 return templ_7745c5c3_Err827 return templ_7745c5c3_Err
738 }828 }
739 } else if r.NoCommits {829 } else if r.NoCommits {
740- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"repo-commits mute\">no commits yet</p>")830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"repo-commits mute\">no commits yet</p>")
741 if templ_7745c5c3_Err != nil {831 if templ_7745c5c3_Err != nil {
742 return templ_7745c5c3_Err832 return templ_7745c5c3_Err
743 }833 }
744 }834 }
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</li>")835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</li>")
746 if templ_7745c5c3_Err != nil {836 if templ_7745c5c3_Err != nil {
747 return templ_7745c5c3_Err837 return templ_7745c5c3_Err
748 }838 }
@@ -770,113 +860,113 @@ func pulse(h *Heartbeat) templ.Component {
770 }()860 }()
771 }861 }
772 ctx = templ.InitializeContext(ctx)862 ctx = templ.InitializeContext(ctx)
773- templ_7745c5c3_Var38 := templ.GetChildren(ctx)863+ templ_7745c5c3_Var44 := templ.GetChildren(ctx)
774- if templ_7745c5c3_Var38 == nil {864+ if templ_7745c5c3_Var44 == nil {
775- templ_7745c5c3_Var38 = templ.NopComponent865+ templ_7745c5c3_Var44 = templ.NopComponent
776 }866 }
777 ctx = templ.ClearChildren(ctx)867 ctx = templ.ClearChildren(ctx)
778- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<figure class=\"repo-pulse\"")868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<figure class=\"repo-pulse\"")
779 if templ_7745c5c3_Err != nil {869 if templ_7745c5c3_Err != nil {
780 return templ_7745c5c3_Err870 return templ_7745c5c3_Err
781 }871 }
782 if h.Live {872 if h.Live {
783- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " data-live")873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " data-live")
784 if templ_7745c5c3_Err != nil {874 if templ_7745c5c3_Err != nil {
785 return templ_7745c5c3_Err875 return templ_7745c5c3_Err
786 }876 }
787 }877 }
788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "><svg viewBox=\"0 0 600 64\" role=\"img\" aria-label=\"")878+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "><svg viewBox=\"0 0 600 64\" role=\"img\" aria-label=\"")
789 if templ_7745c5c3_Err != nil {879 if templ_7745c5c3_Err != nil {
790 return templ_7745c5c3_Err880 return templ_7745c5c3_Err
791 }881 }
792- var templ_7745c5c3_Var39 string882+ var templ_7745c5c3_Var45 string
793- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Label)883+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Label)
794 if templ_7745c5c3_Err != nil {884 if templ_7745c5c3_Err != nil {
795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 131, Col: 59}885+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 59}
796 }886 }
797- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)887+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
798 if templ_7745c5c3_Err != nil {888 if templ_7745c5c3_Err != nil {
799 return templ_7745c5c3_Err889 return templ_7745c5c3_Err
800 }890 }
801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" focusable=\"false\"><defs><linearGradient id=\"")891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" focusable=\"false\"><defs><linearGradient id=\"")
802 if templ_7745c5c3_Err != nil {892 if templ_7745c5c3_Err != nil {
803 return templ_7745c5c3_Err893 return templ_7745c5c3_Err
804 }894 }
805- var templ_7745c5c3_Var40 string895+ var templ_7745c5c3_Var46 string
806- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.GradID)896+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.GradID)
807 if templ_7745c5c3_Err != nil {897 if templ_7745c5c3_Err != nil {
808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 133, Col: 33}898+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 139, Col: 33}
809 }899 }
810- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)900+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
811 if templ_7745c5c3_Err != nil {901 if templ_7745c5c3_Err != nil {
812 return templ_7745c5c3_Err902 return templ_7745c5c3_Err
813 }903 }
814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop class=\"pulse-stop-top\" offset=\"0\"></stop> <stop class=\"pulse-stop-bottom\" offset=\"1\"></stop></linearGradient></defs> <path class=\"pulse-grid\" d=\"M0 21.5 H600 M0 42.5 H600 M0 63.5 H600\" vector-effect=\"non-scaling-stroke\"></path> <path class=\"pulse-area\" d=\"")904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop class=\"pulse-stop-top\" offset=\"0\"></stop> <stop class=\"pulse-stop-bottom\" offset=\"1\"></stop></linearGradient></defs> <path class=\"pulse-grid\" d=\"M0 21.5 H600 M0 42.5 H600 M0 63.5 H600\" vector-effect=\"non-scaling-stroke\"></path> <path class=\"pulse-area\" d=\"")
815 if templ_7745c5c3_Err != nil {905 if templ_7745c5c3_Err != nil {
816 return templ_7745c5c3_Err906 return templ_7745c5c3_Err
817 }907 }
818- var templ_7745c5c3_Var41 string908+ var templ_7745c5c3_Var47 string
819- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Area)909+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Area)
820 if templ_7745c5c3_Err != nil {910 if templ_7745c5c3_Err != nil {
821- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 139, Col: 38}911+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 38}
822 }912 }
823- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)913+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
824 if templ_7745c5c3_Err != nil {914 if templ_7745c5c3_Err != nil {
825 return templ_7745c5c3_Err915 return templ_7745c5c3_Err
826 }916 }
827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" fill=\"")917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" fill=\"")
828 if templ_7745c5c3_Err != nil {918 if templ_7745c5c3_Err != nil {
829 return templ_7745c5c3_Err919 return templ_7745c5c3_Err
830 }920 }
831- var templ_7745c5c3_Var42 string921+ var templ_7745c5c3_Var48 string
832- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("url(#" + h.GradID + ")")922+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue("url(#" + h.GradID + ")")
833 if templ_7745c5c3_Err != nil {923 if templ_7745c5c3_Err != nil {
834- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 139, Col: 72}924+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 72}
835 }925 }
836- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)926+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
837 if templ_7745c5c3_Err != nil {927 if templ_7745c5c3_Err != nil {
838 return templ_7745c5c3_Err928 return templ_7745c5c3_Err
839 }929 }
840- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"></path> <path class=\"pulse-line\" d=\"")930+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"></path> <path class=\"pulse-line\" d=\"")
841 if templ_7745c5c3_Err != nil {931 if templ_7745c5c3_Err != nil {
842 return templ_7745c5c3_Err932 return templ_7745c5c3_Err
843 }933 }
844- var templ_7745c5c3_Var43 string934+ var templ_7745c5c3_Var49 string
845- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Line)935+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Line)
846 if templ_7745c5c3_Err != nil {936 if templ_7745c5c3_Err != nil {
847- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 140, Col: 38}937+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 146, Col: 38}
848 }938 }
849- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)939+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
850 if templ_7745c5c3_Err != nil {940 if templ_7745c5c3_Err != nil {
851 return templ_7745c5c3_Err941 return templ_7745c5c3_Err
852 }942 }
853- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" vector-effect=\"non-scaling-stroke\"></path> <circle class=\"pulse-dot\" cx=\"")943+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" vector-effect=\"non-scaling-stroke\"></path> <circle class=\"pulse-dot\" cx=\"")
854 if templ_7745c5c3_Err != nil {944 if templ_7745c5c3_Err != nil {
855 return templ_7745c5c3_Err945 return templ_7745c5c3_Err
856 }946 }
857- var templ_7745c5c3_Var44 string947+ var templ_7745c5c3_Var50 string
858- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)948+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
859 if templ_7745c5c3_Err != nil {949 if templ_7745c5c3_Err != nil {
860- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 141, Col: 37}950+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 37}
861 }951 }
862- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)952+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
863 if templ_7745c5c3_Err != nil {953 if templ_7745c5c3_Err != nil {
864 return templ_7745c5c3_Err954 return templ_7745c5c3_Err
865 }955 }
866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" cy=\"")956+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" cy=\"")
867 if templ_7745c5c3_Err != nil {957 if templ_7745c5c3_Err != nil {
868 return templ_7745c5c3_Err958 return templ_7745c5c3_Err
869 }959 }
870- var templ_7745c5c3_Var45 string960+ var templ_7745c5c3_Var51 string
871- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)961+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
872 if templ_7745c5c3_Err != nil {962 if templ_7745c5c3_Err != nil {
873- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 141, Col: 48}963+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 48}
874 }964 }
875- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)965+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
876 if templ_7745c5c3_Err != nil {966 if templ_7745c5c3_Err != nil {
877 return templ_7745c5c3_Err967 return templ_7745c5c3_Err
878 }968 }
879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" r=\"3.5\"></circle></svg></figure>")969+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" r=\"3.5\"></circle></svg></figure>")
880 if templ_7745c5c3_Err != nil {970 if templ_7745c5c3_Err != nil {
881 return templ_7745c5c3_Err971 return templ_7745c5c3_Err
882 }972 }
@@ -901,66 +991,66 @@ func Outline(headings []Heading) templ.Component {
901 }()991 }()
902 }992 }
903 ctx = templ.InitializeContext(ctx)993 ctx = templ.InitializeContext(ctx)
904- templ_7745c5c3_Var46 := templ.GetChildren(ctx)994+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
905- if templ_7745c5c3_Var46 == nil {995+ if templ_7745c5c3_Var52 == nil {
906- templ_7745c5c3_Var46 = templ.NopComponent996+ templ_7745c5c3_Var52 = templ.NopComponent
907 }997 }
908 ctx = templ.ClearChildren(ctx)998 ctx = templ.ClearChildren(ctx)
909- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<nav class=\"outline\" aria-label=\"Outline\">")999+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<nav class=\"outline\" aria-label=\"Outline\">")
910 if templ_7745c5c3_Err != nil {1000 if templ_7745c5c3_Err != nil {
911 return templ_7745c5c3_Err1001 return templ_7745c5c3_Err
912 }1002 }
913 for _, h := range headings {1003 for _, h := range headings {
914- var templ_7745c5c3_Var47 = []any{"l" + itoa(h.Level)}1004+ var templ_7745c5c3_Var53 = []any{"l" + itoa(h.Level)}
915- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...)1005+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var53...)
916 if templ_7745c5c3_Err != nil {1006 if templ_7745c5c3_Err != nil {
917 return templ_7745c5c3_Err1007 return templ_7745c5c3_Err
918 }1008 }
919- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<a class=\"")1009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<a class=\"")
920 if templ_7745c5c3_Err != nil {1010 if templ_7745c5c3_Err != nil {
921 return templ_7745c5c3_Err1011 return templ_7745c5c3_Err
922 }1012 }
923- var templ_7745c5c3_Var48 string1013+ var templ_7745c5c3_Var54 string
924- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var47).String())1014+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var53).String())
925 if templ_7745c5c3_Err != nil {1015 if templ_7745c5c3_Err != nil {
926 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}1016 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
927 }1017 }
928- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)1018+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
929 if templ_7745c5c3_Err != nil {1019 if templ_7745c5c3_Err != nil {
930 return templ_7745c5c3_Err1020 return templ_7745c5c3_Err
931 }1021 }
932- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" href=\"")1022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" href=\"")
933 if templ_7745c5c3_Err != nil {1023 if templ_7745c5c3_Err != nil {
934 return templ_7745c5c3_Err1024 return templ_7745c5c3_Err
935 }1025 }
936- var templ_7745c5c3_Var49 templ.SafeURL1026+ var templ_7745c5c3_Var55 templ.SafeURL
937- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))1027+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
938 if templ_7745c5c3_Err != nil {1028 if templ_7745c5c3_Err != nil {
939- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 68}1029+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 156, Col: 68}
940 }1030 }
941- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1031+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
942 if templ_7745c5c3_Err != nil {1032 if templ_7745c5c3_Err != nil {
943 return templ_7745c5c3_Err1033 return templ_7745c5c3_Err
944 }1034 }
945- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\">")1035+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\">")
946 if templ_7745c5c3_Err != nil {1036 if templ_7745c5c3_Err != nil {
947 return templ_7745c5c3_Err1037 return templ_7745c5c3_Err
948 }1038 }
949- var templ_7745c5c3_Var50 string1039+ var templ_7745c5c3_Var56 string
950- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)1040+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
951 if templ_7745c5c3_Err != nil {1041 if templ_7745c5c3_Err != nil {
952- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 79}1042+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 156, Col: 79}
953 }1043 }
954- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))1044+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
955 if templ_7745c5c3_Err != nil {1045 if templ_7745c5c3_Err != nil {
956 return templ_7745c5c3_Err1046 return templ_7745c5c3_Err
957 }1047 }
958- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</a>")1048+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</a>")
959 if templ_7745c5c3_Err != nil {1049 if templ_7745c5c3_Err != nil {
960 return templ_7745c5c3_Err1050 return templ_7745c5c3_Err
961 }1051 }
962 }1052 }
963- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</nav>")1053+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</nav>")
964 if templ_7745c5c3_Err != nil {1054 if templ_7745c5c3_Err != nil {
965 return templ_7745c5c3_Err1055 return templ_7745c5c3_Err
966 }1056 }
@@ -969,7 +1059,7 @@ func Outline(headings []Heading) templ.Component {
969 }1059 }
970 1060
971 // ReadmeCard is a README in a card whose header names the file.1061 // ReadmeCard is a README in a card whose header names the file.
972-func ReadmeCard(r Readme) templ.Component {1062+func ReadmeCard(r Readme, docs ...DocTab) templ.Component {
973 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {1063 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
974 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context1064 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
975 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {1065 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -985,33 +1075,103 @@ func ReadmeCard(r Readme) templ.Component {
985 }()1075 }()
986 }1076 }
987 ctx = templ.InitializeContext(ctx)1077 ctx = templ.InitializeContext(ctx)
988- templ_7745c5c3_Var51 := templ.GetChildren(ctx)1078+ templ_7745c5c3_Var57 := templ.GetChildren(ctx)
989- if templ_7745c5c3_Var51 == nil {1079+ if templ_7745c5c3_Var57 == nil {
990- templ_7745c5c3_Var51 = templ.NopComponent1080+ templ_7745c5c3_Var57 = templ.NopComponent
991 }1081 }
992 ctx = templ.ClearChildren(ctx)1082 ctx = templ.ClearChildren(ctx)
993- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")1083+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<section class=\"card readme\" aria-label=\"")
994 if templ_7745c5c3_Err != nil {1084 if templ_7745c5c3_Err != nil {
995 return templ_7745c5c3_Err1085 return templ_7745c5c3_Err
996 }1086 }
997- var templ_7745c5c3_Var52 string1087+ var templ_7745c5c3_Var58 string
998- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1088+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
999 if templ_7745c5c3_Err != nil {1089 if templ_7745c5c3_Err != nil {
1000- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 160, Col: 11}1090+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 49}
1001 }1091 }
1002- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))1092+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
1003 if templ_7745c5c3_Err != nil {1093 if templ_7745c5c3_Err != nil {
1004 return templ_7745c5c3_Err1094 return templ_7745c5c3_Err
1005 }1095 }
1006- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</h2>")1096+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">")
1007 if templ_7745c5c3_Err != nil {1097 if templ_7745c5c3_Err != nil {
1008 return templ_7745c5c3_Err1098 return templ_7745c5c3_Err
1009 }1099 }
1100+ if len(docs) > 1 {
1101+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<nav class=\"card-h doc-tabs\" aria-label=\"Repository documents\">")
1102+ if templ_7745c5c3_Err != nil {
1103+ return templ_7745c5c3_Err
1104+ }
1105+ for _, doc := range docs {
1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<a href=\"")
1107+ if templ_7745c5c3_Err != nil {
1108+ return templ_7745c5c3_Err
1109+ }
1110+ var templ_7745c5c3_Var59 templ.SafeURL
1111+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(doc.Href))
1112+ if templ_7745c5c3_Err != nil {
1113+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 167, Col: 38}
1114+ }
1115+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1116+ if templ_7745c5c3_Err != nil {
1117+ return templ_7745c5c3_Err
1118+ }
1119+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"")
1120+ if templ_7745c5c3_Err != nil {
1121+ return templ_7745c5c3_Err
1122+ }
1123+ if doc.Current {
1124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " aria-current=\"page\"")
1125+ if templ_7745c5c3_Err != nil {
1126+ return templ_7745c5c3_Err
1127+ }
1128+ }
1129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, ">")
1130+ if templ_7745c5c3_Err != nil {
1131+ return templ_7745c5c3_Err
1132+ }
1133+ var templ_7745c5c3_Var60 string
1134+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Name)
1135+ if templ_7745c5c3_Err != nil {
1136+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 171, Col: 16}
1137+ }
1138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1139+ if templ_7745c5c3_Err != nil {
1140+ return templ_7745c5c3_Err
1141+ }
1142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</a>")
1143+ if templ_7745c5c3_Err != nil {
1144+ return templ_7745c5c3_Err
1145+ }
1146+ }
1147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</nav>")
1148+ if templ_7745c5c3_Err != nil {
1149+ return templ_7745c5c3_Err
1150+ }
1151+ } else {
1152+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
1153+ if templ_7745c5c3_Err != nil {
1154+ return templ_7745c5c3_Err
1155+ }
1156+ var templ_7745c5c3_Var61 string
1157+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1158+ if templ_7745c5c3_Err != nil {
1159+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 177, Col: 12}
1160+ }
1161+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1162+ if templ_7745c5c3_Err != nil {
1163+ return templ_7745c5c3_Err
1164+ }
1165+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</h2>")
1166+ if templ_7745c5c3_Err != nil {
1167+ return templ_7745c5c3_Err
1168+ }
1169+ }
1010 templ_7745c5c3_Err = ReadmeView(r).Render(ctx, templ_7745c5c3_Buffer)1170 templ_7745c5c3_Err = ReadmeView(r).Render(ctx, templ_7745c5c3_Buffer)
1011 if templ_7745c5c3_Err != nil {1171 if templ_7745c5c3_Err != nil {
1012 return templ_7745c5c3_Err1172 return templ_7745c5c3_Err
1013 }1173 }
1014- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</section>")1174+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</section>")
1015 if templ_7745c5c3_Err != nil {1175 if templ_7745c5c3_Err != nil {
1016 return templ_7745c5c3_Err1176 return templ_7745c5c3_Err
1017 }1177 }
@@ -1037,43 +1197,43 @@ func ReadmeView(r Readme) templ.Component {
1037 }()1197 }()
1038 }1198 }
1039 ctx = templ.InitializeContext(ctx)1199 ctx = templ.InitializeContext(ctx)
1040- templ_7745c5c3_Var53 := templ.GetChildren(ctx)1200+ templ_7745c5c3_Var62 := templ.GetChildren(ctx)
1041- if templ_7745c5c3_Var53 == nil {1201+ if templ_7745c5c3_Var62 == nil {
1042- templ_7745c5c3_Var53 = templ.NopComponent1202+ templ_7745c5c3_Var62 = templ.NopComponent
1043 }1203 }
1044 ctx = templ.ClearChildren(ctx)1204 ctx = templ.ClearChildren(ctx)
1045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<article class=\"prose\" aria-label=\"")1205+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<article class=\"prose\" aria-label=\"")
1046 if templ_7745c5c3_Err != nil {1206 if templ_7745c5c3_Err != nil {
1047 return templ_7745c5c3_Err1207 return templ_7745c5c3_Err
1048 }1208 }
1049- var templ_7745c5c3_Var54 string1209+ var templ_7745c5c3_Var63 string
1050- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)1210+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
1051 if templ_7745c5c3_Err != nil {1211 if templ_7745c5c3_Err != nil {
1052- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 169, Col: 43}1212+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 187, Col: 43}
1053 }1213 }
1054- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)1214+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1055 if templ_7745c5c3_Err != nil {1215 if templ_7745c5c3_Err != nil {
1056 return templ_7745c5c3_Err1216 return templ_7745c5c3_Err
1057 }1217 }
1058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" hx-ignore>")1218+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" hx-ignore>")
1059 if templ_7745c5c3_Err != nil {1219 if templ_7745c5c3_Err != nil {
1060 return templ_7745c5c3_Err1220 return templ_7745c5c3_Err
1061 }1221 }
1062 if r.Note != "" {1222 if r.Note != "" {
1063- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<p class=\"dim\">")1223+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">")
1064 if templ_7745c5c3_Err != nil {1224 if templ_7745c5c3_Err != nil {
1065 return templ_7745c5c3_Err1225 return templ_7745c5c3_Err
1066 }1226 }
1067- var templ_7745c5c3_Var55 string1227+ var templ_7745c5c3_Var64 string
1068- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)1228+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
1069 if templ_7745c5c3_Err != nil {1229 if templ_7745c5c3_Err != nil {
1070- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 171, Col: 26}1230+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 189, Col: 26}
1071 }1231 }
1072- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))1232+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1073 if templ_7745c5c3_Err != nil {1233 if templ_7745c5c3_Err != nil {
1074 return templ_7745c5c3_Err1234 return templ_7745c5c3_Err
1075 }1235 }
1076- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</p>")1236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p>")
1077 if templ_7745c5c3_Err != nil {1237 if templ_7745c5c3_Err != nil {
1078 return templ_7745c5c3_Err1238 return templ_7745c5c3_Err
1079 }1239 }
@@ -1083,7 +1243,7 @@ func ReadmeView(r Readme) templ.Component {
1083 return templ_7745c5c3_Err1243 return templ_7745c5c3_Err
1084 }1244 }
1085 }1245 }
1086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</article>")1246+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</article>")
1087 if templ_7745c5c3_Err != nil {1247 if templ_7745c5c3_Err != nil {
1088 return templ_7745c5c3_Err1248 return templ_7745c5c3_Err
1089 }1249 }
@@ -1109,90 +1269,90 @@ func CommitRows(rows []CommitRow) templ.Component {
1109 }()1269 }()
1110 }1270 }
1111 ctx = templ.InitializeContext(ctx)1271 ctx = templ.InitializeContext(ctx)
1112- templ_7745c5c3_Var56 := templ.GetChildren(ctx)1272+ templ_7745c5c3_Var65 := templ.GetChildren(ctx)
1113- if templ_7745c5c3_Var56 == nil {1273+ if templ_7745c5c3_Var65 == nil {
1114- templ_7745c5c3_Var56 = templ.NopComponent1274+ templ_7745c5c3_Var65 = templ.NopComponent
1115 }1275 }
1116 ctx = templ.ClearChildren(ctx)1276 ctx = templ.ClearChildren(ctx)
1117- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")1277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
1118 if templ_7745c5c3_Err != nil {1278 if templ_7745c5c3_Err != nil {
1119 return templ_7745c5c3_Err1279 return templ_7745c5c3_Err
1120 }1280 }
1121 for _, c := range rows {1281 for _, c := range rows {
1122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<tr")1282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<tr")
1123 if templ_7745c5c3_Err != nil {1283 if templ_7745c5c3_Err != nil {
1124 return templ_7745c5c3_Err1284 return templ_7745c5c3_Err
1125 }1285 }
1126 if c.ID != "" {1286 if c.ID != "" {
1127- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " id=\"")1287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " id=\"")
1128 if templ_7745c5c3_Err != nil {1288 if templ_7745c5c3_Err != nil {
1129 return templ_7745c5c3_Err1289 return templ_7745c5c3_Err
1130 }1290 }
1131- var templ_7745c5c3_Var57 string1291+ var templ_7745c5c3_Var66 string
1132- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)1292+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
1133 if templ_7745c5c3_Err != nil {1293 if templ_7745c5c3_Err != nil {
1134- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 186, Col: 15}1294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 204, Col: 15}
1135 }1295 }
1136- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)1296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1137 if templ_7745c5c3_Err != nil {1297 if templ_7745c5c3_Err != nil {
1138 return templ_7745c5c3_Err1298 return templ_7745c5c3_Err
1139 }1299 }
1140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"")1300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\"")
1141 if templ_7745c5c3_Err != nil {1301 if templ_7745c5c3_Err != nil {
1142 return templ_7745c5c3_Err1302 return templ_7745c5c3_Err
1143 }1303 }
1144 }1304 }
1145 if c.Cursor {1305 if c.Cursor {
1146- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " data-cursor")1306+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, " data-cursor")
1147 if templ_7745c5c3_Err != nil {1307 if templ_7745c5c3_Err != nil {
1148 return templ_7745c5c3_Err1308 return templ_7745c5c3_Err
1149 }1309 }
1150 }1310 }
1151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "><td class=\"c-sha mute\">")1311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "><td class=\"c-sha mute\">")
1152 if templ_7745c5c3_Err != nil {1312 if templ_7745c5c3_Err != nil {
1153 return templ_7745c5c3_Err1313 return templ_7745c5c3_Err
1154 }1314 }
1155- var templ_7745c5c3_Var58 string1315+ var templ_7745c5c3_Var67 string
1156- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)1316+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
1157 if templ_7745c5c3_Err != nil {1317 if templ_7745c5c3_Err != nil {
1158- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 190, Col: 37}1318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 37}
1159 }1319 }
1160- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))1320+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1161 if templ_7745c5c3_Err != nil {1321 if templ_7745c5c3_Err != nil {
1162 return templ_7745c5c3_Err1322 return templ_7745c5c3_Err
1163 }1323 }
1164- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</td><td class=\"c-subject\"><a href=\"")1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</td><td class=\"c-subject\"><a href=\"")
1165 if templ_7745c5c3_Err != nil {1325 if templ_7745c5c3_Err != nil {
1166 return templ_7745c5c3_Err1326 return templ_7745c5c3_Err
1167 }1327 }
1168- var templ_7745c5c3_Var59 templ.SafeURL1328+ var templ_7745c5c3_Var68 templ.SafeURL
1169- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))1329+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
1170 if templ_7745c5c3_Err != nil {1330 if templ_7745c5c3_Err != nil {
1171- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 192, Col: 38}1331+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 210, Col: 38}
1172 }1332 }
1173- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))1333+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1174 if templ_7745c5c3_Err != nil {1334 if templ_7745c5c3_Err != nil {
1175 return templ_7745c5c3_Err1335 return templ_7745c5c3_Err
1176 }1336 }
1177- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">")1337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
1178 if templ_7745c5c3_Err != nil {1338 if templ_7745c5c3_Err != nil {
1179 return templ_7745c5c3_Err1339 return templ_7745c5c3_Err
1180 }1340 }
1181- var templ_7745c5c3_Var60 string1341+ var templ_7745c5c3_Var69 string
1182- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)1342+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
1183 if templ_7745c5c3_Err != nil {1343 if templ_7745c5c3_Err != nil {
1184- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 192, Col: 52}1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 210, Col: 52}
1185 }1345 }
1186- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1187 if templ_7745c5c3_Err != nil {1347 if templ_7745c5c3_Err != nil {
1188 return templ_7745c5c3_Err1348 return templ_7745c5c3_Err
1189 }1349 }
1190- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</a> ")1350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</a> ")
1191 if templ_7745c5c3_Err != nil {1351 if templ_7745c5c3_Err != nil {
1192 return templ_7745c5c3_Err1352 return templ_7745c5c3_Err
1193 }1353 }
1194 if c.Merge {1354 if c.Merge {
1195- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<span class=\"pill\">merge</span> ")1355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span class=\"pill\">merge</span> ")
1196 if templ_7745c5c3_Err != nil {1356 if templ_7745c5c3_Err != nil {
1197 return templ_7745c5c3_Err1357 return templ_7745c5c3_Err
1198 }1358 }
@@ -1204,44 +1364,44 @@ func CommitRows(rows []CommitRow) templ.Component {
1204 }1364 }
1205 }1365 }
1206 for _, b := range c.Branches {1366 for _, b := range c.Branches {
1207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<span class=\"pill\">")1367+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"pill\">")
1208 if templ_7745c5c3_Err != nil {1368 if templ_7745c5c3_Err != nil {
1209 return templ_7745c5c3_Err1369 return templ_7745c5c3_Err
1210 }1370 }
1211- var templ_7745c5c3_Var61 string1371+ var templ_7745c5c3_Var70 string
1212- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(b)1372+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(b)
1213 if templ_7745c5c3_Err != nil {1373 if templ_7745c5c3_Err != nil {
1214- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 200, Col: 29}1374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 218, Col: 29}
1215 }1375 }
1216- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))1376+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1217 if templ_7745c5c3_Err != nil {1377 if templ_7745c5c3_Err != nil {
1218 return templ_7745c5c3_Err1378 return templ_7745c5c3_Err
1219 }1379 }
1220- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span> ")1380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</span> ")
1221 if templ_7745c5c3_Err != nil {1381 if templ_7745c5c3_Err != nil {
1222 return templ_7745c5c3_Err1382 return templ_7745c5c3_Err
1223 }1383 }
1224 }1384 }
1225 for _, t := range c.Tags {1385 for _, t := range c.Tags {
1226- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
1227 if templ_7745c5c3_Err != nil {1387 if templ_7745c5c3_Err != nil {
1228 return templ_7745c5c3_Err1388 return templ_7745c5c3_Err
1229 }1389 }
1230- var templ_7745c5c3_Var62 string1390+ var templ_7745c5c3_Var71 string
1231- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(t)1391+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(t)
1232 if templ_7745c5c3_Err != nil {1392 if templ_7745c5c3_Err != nil {
1233- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 203, Col: 61}1393+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 221, Col: 61}
1234 }1394 }
1235- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))1395+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1236 if templ_7745c5c3_Err != nil {1396 if templ_7745c5c3_Err != nil {
1237 return templ_7745c5c3_Err1397 return templ_7745c5c3_Err
1238 }1398 }
1239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</span>")1399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</span>")
1240 if templ_7745c5c3_Err != nil {1400 if templ_7745c5c3_Err != nil {
1241 return templ_7745c5c3_Err1401 return templ_7745c5c3_Err
1242 }1402 }
1243 }1403 }
1244- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td><td class=\"c-ck\">")1404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td><td class=\"c-ck\">")
1245 if templ_7745c5c3_Err != nil {1405 if templ_7745c5c3_Err != nil {
1246 return templ_7745c5c3_Err1406 return templ_7745c5c3_Err
1247 }1407 }
@@ -1249,64 +1409,64 @@ func CommitRows(rows []CommitRow) templ.Component {
1249 if templ_7745c5c3_Err != nil {1409 if templ_7745c5c3_Err != nil {
1250 return templ_7745c5c3_Err1410 return templ_7745c5c3_Err
1251 }1411 }
1252- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</td><td class=\"c-who dim\">")1412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</td><td class=\"c-who dim\">")
1253 if templ_7745c5c3_Err != nil {1413 if templ_7745c5c3_Err != nil {
1254 return templ_7745c5c3_Err1414 return templ_7745c5c3_Err
1255 }1415 }
1256- var templ_7745c5c3_Var63 string1416+ var templ_7745c5c3_Var72 string
1257- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)1417+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1258 if templ_7745c5c3_Err != nil {1418 if templ_7745c5c3_Err != nil {
1259- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 207, Col: 37}1419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 225, Col: 37}
1260 }1420 }
1261- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))1421+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
1262 if templ_7745c5c3_Err != nil {1422 if templ_7745c5c3_Err != nil {
1263 return templ_7745c5c3_Err1423 return templ_7745c5c3_Err
1264 }1424 }
1265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")1425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
1266 if templ_7745c5c3_Err != nil {1426 if templ_7745c5c3_Err != nil {
1267 return templ_7745c5c3_Err1427 return templ_7745c5c3_Err
1268 }1428 }
1269- var templ_7745c5c3_Var64 string1429+ var templ_7745c5c3_Var73 string
1270- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1430+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1271 if templ_7745c5c3_Err != nil {1431 if templ_7745c5c3_Err != nil {
1272- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 60}1432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 226, Col: 60}
1273 }1433 }
1274- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)1434+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
1275 if templ_7745c5c3_Err != nil {1435 if templ_7745c5c3_Err != nil {
1276 return templ_7745c5c3_Err1436 return templ_7745c5c3_Err
1277 }1437 }
1278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" title=\"")1438+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" title=\"")
1279 if templ_7745c5c3_Err != nil {1439 if templ_7745c5c3_Err != nil {
1280 return templ_7745c5c3_Err1440 return templ_7745c5c3_Err
1281 }1441 }
1282- var templ_7745c5c3_Var65 string1442+ var templ_7745c5c3_Var74 string
1283- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1443+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1284 if templ_7745c5c3_Err != nil {1444 if templ_7745c5c3_Err != nil {
1285- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 79}1445+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 226, Col: 79}
1286 }1446 }
1287- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)1447+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1288 if templ_7745c5c3_Err != nil {1448 if templ_7745c5c3_Err != nil {
1289 return templ_7745c5c3_Err1449 return templ_7745c5c3_Err
1290 }1450 }
1291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">")1451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">")
1292 if templ_7745c5c3_Err != nil {1452 if templ_7745c5c3_Err != nil {
1293 return templ_7745c5c3_Err1453 return templ_7745c5c3_Err
1294 }1454 }
1295- var templ_7745c5c3_Var66 string1455+ var templ_7745c5c3_Var75 string
1296- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)1456+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1297 if templ_7745c5c3_Err != nil {1457 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 208, Col: 90}1458+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 226, Col: 90}
1299 }1459 }
1300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))1460+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1301 if templ_7745c5c3_Err != nil {1461 if templ_7745c5c3_Err != nil {
1302 return templ_7745c5c3_Err1462 return templ_7745c5c3_Err
1303 }1463 }
1304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</time></td></tr>")1464+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</time></td></tr>")
1305 if templ_7745c5c3_Err != nil {1465 if templ_7745c5c3_Err != nil {
1306 return templ_7745c5c3_Err1466 return templ_7745c5c3_Err
1307 }1467 }
1308 }1468 }
1309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</tbody></table>")1469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</tbody></table>")
1310 if templ_7745c5c3_Err != nil {1470 if templ_7745c5c3_Err != nil {
1311 return templ_7745c5c3_Err1471 return templ_7745c5c3_Err
1312 }1472 }
@@ -1332,75 +1492,75 @@ func signaturePill(b *SignatureBadge) templ.Component {
1332 }()1492 }()
1333 }1493 }
1334 ctx = templ.InitializeContext(ctx)1494 ctx = templ.InitializeContext(ctx)
1335- templ_7745c5c3_Var67 := templ.GetChildren(ctx)1495+ templ_7745c5c3_Var76 := templ.GetChildren(ctx)
1336- if templ_7745c5c3_Var67 == nil {1496+ if templ_7745c5c3_Var76 == nil {
1337- templ_7745c5c3_Var67 = templ.NopComponent1497+ templ_7745c5c3_Var76 = templ.NopComponent
1338 }1498 }
1339 ctx = templ.ClearChildren(ctx)1499 ctx = templ.ClearChildren(ctx)
1340- var templ_7745c5c3_Var68 = []any{"pill sig", b.Class}1500+ var templ_7745c5c3_Var77 = []any{"pill sig", b.Class}
1341- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...)1501+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var77...)
1342 if templ_7745c5c3_Err != nil {1502 if templ_7745c5c3_Err != nil {
1343 return templ_7745c5c3_Err1503 return templ_7745c5c3_Err
1344 }1504 }
1345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<span class=\"")1505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<span class=\"")
1346 if templ_7745c5c3_Err != nil {1506 if templ_7745c5c3_Err != nil {
1347 return templ_7745c5c3_Err1507 return templ_7745c5c3_Err
1348 }1508 }
1349- var templ_7745c5c3_Var69 string1509+ var templ_7745c5c3_Var78 string
1350- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var68).String())1510+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var77).String())
1351 if templ_7745c5c3_Err != nil {1511 if templ_7745c5c3_Err != nil {
1352 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}1512 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
1353 }1513 }
1354- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)1514+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1355 if templ_7745c5c3_Err != nil {1515 if templ_7745c5c3_Err != nil {
1356 return templ_7745c5c3_Err1516 return templ_7745c5c3_Err
1357 }1517 }
1358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" title=\"")1518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\" title=\"")
1359 if templ_7745c5c3_Err != nil {1519 if templ_7745c5c3_Err != nil {
1360 return templ_7745c5c3_Err1520 return templ_7745c5c3_Err
1361 }1521 }
1362- var templ_7745c5c3_Var70 string1522+ var templ_7745c5c3_Var79 string
1363- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(b.Title)1523+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(b.Title)
1364 if templ_7745c5c3_Err != nil {1524 if templ_7745c5c3_Err != nil {
1365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 218, Col: 52}1525+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 236, Col: 52}
1366 }1526 }
1367- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)1527+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1368 if templ_7745c5c3_Err != nil {1528 if templ_7745c5c3_Err != nil {
1369 return templ_7745c5c3_Err1529 return templ_7745c5c3_Err
1370 }1530 }
1371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">")1531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">")
1372 if templ_7745c5c3_Err != nil {1532 if templ_7745c5c3_Err != nil {
1373 return templ_7745c5c3_Err1533 return templ_7745c5c3_Err
1374 }1534 }
1375 if b.Proto != "" {1535 if b.Proto != "" {
1376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<span class=\"sig-proto\">")1536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<span class=\"sig-proto\">")
1377 if templ_7745c5c3_Err != nil {1537 if templ_7745c5c3_Err != nil {
1378 return templ_7745c5c3_Err1538 return templ_7745c5c3_Err
1379 }1539 }
1380- var templ_7745c5c3_Var71 string1540+ var templ_7745c5c3_Var80 string
1381- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(b.Proto)1541+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(b.Proto)
1382 if templ_7745c5c3_Err != nil {1542 if templ_7745c5c3_Err != nil {
1383- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 220, Col: 36}1543+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 238, Col: 36}
1384 }1544 }
1385- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))1545+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
1386 if templ_7745c5c3_Err != nil {1546 if templ_7745c5c3_Err != nil {
1387 return templ_7745c5c3_Err1547 return templ_7745c5c3_Err
1388 }1548 }
1389- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</span> ")1549+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</span> ")
1390 if templ_7745c5c3_Err != nil {1550 if templ_7745c5c3_Err != nil {
1391 return templ_7745c5c3_Err1551 return templ_7745c5c3_Err
1392 }1552 }
1393 }1553 }
1394- var templ_7745c5c3_Var72 string1554+ var templ_7745c5c3_Var81 string
1395- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)1555+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
1396 if templ_7745c5c3_Err != nil {1556 if templ_7745c5c3_Err != nil {
1397- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 222, Col: 11}1557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 240, Col: 11}
1398 }1558 }
1399- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))1559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1400 if templ_7745c5c3_Err != nil {1560 if templ_7745c5c3_Err != nil {
1401 return templ_7745c5c3_Err1561 return templ_7745c5c3_Err
1402 }1562 }
1403- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</span>")1563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "</span>")
1404 if templ_7745c5c3_Err != nil {1564 if templ_7745c5c3_Err != nil {
1405 return templ_7745c5c3_Err1565 return templ_7745c5c3_Err
1406 }1566 }
M internal/adapters/web/fragments.go +8 -0
@@ -135,6 +135,14 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
135135 name, fresh = ev.Issue.Repo.String(), true
136136 case domain.IssueCommented:
137137 name, fresh = ev.Repo.String(), true
138+ case domain.ReleasePublished:
139+ name = ev.Release.Repo.String()
140+ case domain.ReleaseUpdated:
141+ name = ev.Release.Repo.String()
142+ case domain.ReleaseDeleted:
143+ name = ev.Release.Repo.String()
144+ case domain.ReleaseUnpublished:
145+ name = ev.Release.Repo.String()
138146 case domain.RunUpdated:
139147 name = ev.Run.Repo.String() // the CI state of the row
140148 case domain.JobUpdated:
@@ -135,6 +135,14 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
135 name, fresh = ev.Issue.Repo.String(), true135 name, fresh = ev.Issue.Repo.String(), true
136 case domain.IssueCommented:136 case domain.IssueCommented:
137 name, fresh = ev.Repo.String(), true137 name, fresh = ev.Repo.String(), true
138+ case domain.ReleasePublished:
139+ name = ev.Release.Repo.String()
140+ case domain.ReleaseUpdated:
141+ name = ev.Release.Repo.String()
142+ case domain.ReleaseDeleted:
143+ name = ev.Release.Repo.String()
144+ case domain.ReleaseUnpublished:
145+ name = ev.Release.Repo.String()
138 case domain.RunUpdated:146 case domain.RunUpdated:
139 name = ev.Run.Repo.String() // the CI state of the row147 name = ev.Run.Repo.String() // the CI state of the row
140 case domain.JobUpdated:148 case domain.JobUpdated:
M internal/adapters/web/home.go +2 -0
@@ -162,6 +162,7 @@ func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.
162162 // the newest commits of its default branch and the state of its latest run.
163163 func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
164164 row := homeRow(r, now, cursor, SignedIn(ctx))
165+ row.Release = s.summaryRelease(ctx, r.Name, now)
165166 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
166167 row.NoCommits = r.PushedAt.IsZero()
167168 return row
@@ -169,6 +170,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time,
169170 var head string
170171 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
171172 if head != "" {
173+ row.License = s.repositoryLicense(ctx, r.Name, head)
172174 row.CI = s.homeCI(ctx, r, head)
173175 row.Stats = s.homeStats(ctx, r, head, now)
174176 }
@@ -162,6 +162,7 @@ func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.
162 // the newest commits of its default branch and the state of its latest run.162 // the newest commits of its default branch and the state of its latest run.
163 func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {163 func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
164 row := homeRow(r, now, cursor, SignedIn(ctx))164 row := homeRow(r, now, cursor, SignedIn(ctx))
165+ row.Release = s.summaryRelease(ctx, r.Name, now)
165 if r.PushedAt.IsZero() || r.DefaultBranch == "" {166 if r.PushedAt.IsZero() || r.DefaultBranch == "" {
166 row.NoCommits = r.PushedAt.IsZero()167 row.NoCommits = r.PushedAt.IsZero()
167 return row168 return row
@@ -169,6 +170,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time,
169 var head string170 var head string
170 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)171 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
171 if head != "" {172 if head != "" {
173+ row.License = s.repositoryLicense(ctx, r.Name, head)
172 row.CI = s.homeCI(ctx, r, head)174 row.CI = s.homeCI(ctx, r, head)
173 row.Stats = s.homeStats(ctx, r, head, now)175 row.Stats = s.homeStats(ctx, r, head, now)
174 }176 }
M internal/adapters/web/home_test.go +81 -2
@@ -7,6 +7,7 @@ import (
77 "context"
88 "errors"
99 "fmt"
10+ "html"
1011 "net/http"
1112 "net/http/httptest"
1213 "strings"
@@ -50,6 +51,7 @@ func homeCommits(t testing.TB, n int) []domain.Commit {
5051 func homeBrowse(t *testing.T) *mocks.BrowseService {
5152 t.Helper()
5253 b := mocks.NewBrowseService(t)
54+ b.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
5355 b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
5456 Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
5557 return b
@@ -63,6 +65,7 @@ func TestHomeRowFigures(t *testing.T) {
6365 head := objectID(t, fmt.Sprintf("%040x", 2))
6466 now := time.Now()
6567 browse := mocks.NewBrowseService(t)
68+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
6669 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
6770 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
6871 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
@@ -212,8 +215,10 @@ func TestHomeRowCI(t *testing.T) {
212215 }
213216 require.NotNil(t, row.CI)
214217 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215- assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")
216- assert.Contains(t, out, `<span class="sr">CI: </span>`)
218+ assert.Contains(t, out, `<span class="sr">CI: `+tt.label+`</span>`)
219+ assert.NotContains(t, out, `class="ci-label"`)
220+ assert.Contains(t, out, `title="`+html.EscapeString(tt.label+" · "+row.CI.Title)+`"`)
221+ assert.Contains(t, out, `class="ck ck-`+row.CI.Kind+`" aria-hidden="true"`)
217222 })
218223 }
219224 }
@@ -393,3 +398,77 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
393398 assert.Empty(t, got)
394399 }
395400 }
401+
402+func TestHomeReleaseAndLicensePills(t *testing.T) {
403+ t.Parallel()
404+ for _, tc := range []struct {
405+ name string
406+ release domain.Release
407+ err error
408+ visible bool
409+ }{
410+ {name: "official release", release: domain.Release{Tag: "v1.2.0", PublishedAt: fixedNow}, visible: true},
411+ {name: "plain tag only", err: domain.ErrNotFound},
412+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true}},
413+ {name: "unpublished", release: domain.Release{Tag: "pending"}},
414+ } {
415+ t.Run(tc.name, func(t *testing.T) {
416+ t.Parallel()
417+ repo := publicRepo(t, "proj")
418+ repo.Visibility = domain.VisibilityPrivate
419+ svc := mocks.NewReleaseService(t)
420+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
421+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: mocks.NewBrowseService(t), Releases: svc})
422+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
423+ row.Private, row.Fresh = true, true
424+ row.License = &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}
425+ out := renderRow(t, row)
426+ assert.Contains(t, out, `class="pill repo-license" href="/proj/blob/main/LICENSE" title="License: MIT">MIT</a>`)
427+ assert.Regexp(t, `(?s)>private</span>.*repo-license.*class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span></h2>`, out)
428+ if tc.visible {
429+ assert.Regexp(t, `(?s)>proj</a>.*class="pill repo-release".*>v1.2.0</a>.*>private</span>`, out)
430+ assert.Contains(t, out, `href="/proj/releases/tag/v1.2.0"`)
431+ } else {
432+ assert.NotContains(t, out, `repo-release`)
433+ }
434+ })
435+ }
436+ out := renderRow(t, HomeRow{Name: "bare"})
437+ assert.NotContains(t, out, `repo-license`)
438+ assert.NotContains(t, out, `repo-release`)
439+}
440+
441+func TestReleaseEventsRefreshTheHomeRow(t *testing.T) {
442+ t.Parallel()
443+ repo := publicRepo(t, "proj")
444+ rel := domain.Release{Repo: repo.Name, Tag: "v2", PublishedAt: fixedNow}
445+ for _, tc := range []struct {
446+ event domain.Event
447+ latest domain.Release
448+ err error
449+ }{
450+ {domain.ReleasePublished{Release: rel}, rel, nil},
451+ {domain.ReleaseUpdated{Release: rel}, domain.Release{Tag: "v1", PublishedAt: fixedNow}, nil},
452+ {domain.ReleaseDeleted{Release: rel}, domain.Release{}, domain.ErrNotFound},
453+ {domain.ReleaseUnpublished{Release: rel}, domain.Release{}, domain.ErrNotFound},
454+ } {
455+ t.Run(tc.event.EventKind(), func(t *testing.T) {
456+ t.Parallel()
457+ releases := mocks.NewReleaseService(t)
458+ releases.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.latest, tc.err).Once()
459+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t), Releases: releases})
460+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: tc.event})
461+ require.NoError(t, err)
462+ require.Len(t, got, 1)
463+ assert.Equal(t, "home-list", got[0].event)
464+ assert.Contains(t, got[0].html, `id="repo-proj"`)
465+ assert.NotContains(t, got[0].html, `data-activity`)
466+ assert.NotContains(t, got[0].html, `new activity`)
467+ if tc.err != nil {
468+ assert.NotContains(t, got[0].html, `repo-release`)
469+ } else {
470+ assert.Contains(t, got[0].html, `href="/proj/releases/tag/`+tc.latest.Tag+`"`)
471+ }
472+ })
473+ }
474+}
@@ -7,6 +7,7 @@ import (
7 "context"7 "context"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10+ "html"
10 "net/http"11 "net/http"
11 "net/http/httptest"12 "net/http/httptest"
12 "strings"13 "strings"
@@ -50,6 +51,7 @@ func homeCommits(t testing.TB, n int) []domain.Commit {
50 func homeBrowse(t *testing.T) *mocks.BrowseService {51 func homeBrowse(t *testing.T) *mocks.BrowseService {
51 t.Helper()52 t.Helper()
52 b := mocks.NewBrowseService(t)53 b := mocks.NewBrowseService(t)
54+ b.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
53 b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).55 b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
54 Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()56 Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
55 return b57 return b
@@ -63,6 +65,7 @@ func TestHomeRowFigures(t *testing.T) {
63 head := objectID(t, fmt.Sprintf("%040x", 2))65 head := objectID(t, fmt.Sprintf("%040x", 2))
64 now := time.Now()66 now := time.Now()
65 browse := mocks.NewBrowseService(t)67 browse := mocks.NewBrowseService(t)
68+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Maybe()
66 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)69 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
67 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{70 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
68 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},71 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
@@ -212,8 +215,10 @@ func TestHomeRowCI(t *testing.T) {
212 }215 }
213 require.NotNil(t, row.CI)216 require.NotNil(t, row.CI)
214 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)217 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215- assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")218+ assert.Contains(t, out, `<span class="sr">CI: `+tt.label+`</span>`)
216- assert.Contains(t, out, `<span class="sr">CI: </span>`)219+ assert.NotContains(t, out, `class="ci-label"`)
220+ assert.Contains(t, out, `title="`+html.EscapeString(tt.label+" · "+row.CI.Title)+`"`)
221+ assert.Contains(t, out, `class="ck ck-`+row.CI.Kind+`" aria-hidden="true"`)
217 })222 })
218 }223 }
219 }224 }
@@ -393,3 +398,77 @@ func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
393 assert.Empty(t, got)398 assert.Empty(t, got)
394 }399 }
395 }400 }
401+
402+func TestHomeReleaseAndLicensePills(t *testing.T) {
403+ t.Parallel()
404+ for _, tc := range []struct {
405+ name string
406+ release domain.Release
407+ err error
408+ visible bool
409+ }{
410+ {name: "official release", release: domain.Release{Tag: "v1.2.0", PublishedAt: fixedNow}, visible: true},
411+ {name: "plain tag only", err: domain.ErrNotFound},
412+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true}},
413+ {name: "unpublished", release: domain.Release{Tag: "pending"}},
414+ } {
415+ t.Run(tc.name, func(t *testing.T) {
416+ t.Parallel()
417+ repo := publicRepo(t, "proj")
418+ repo.Visibility = domain.VisibilityPrivate
419+ svc := mocks.NewReleaseService(t)
420+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
421+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: mocks.NewBrowseService(t), Releases: svc})
422+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
423+ row.Private, row.Fresh = true, true
424+ row.License = &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}
425+ out := renderRow(t, row)
426+ assert.Contains(t, out, `class="pill repo-license" href="/proj/blob/main/LICENSE" title="License: MIT">MIT</a>`)
427+ assert.Regexp(t, `(?s)>private</span>.*repo-license.*class="repo-activity" title="new activity"><span class="ico i-refresh-cw" aria-hidden="true"></span><span class="sr">new activity</span></span></h2>`, out)
428+ if tc.visible {
429+ assert.Regexp(t, `(?s)>proj</a>.*class="pill repo-release".*>v1.2.0</a>.*>private</span>`, out)
430+ assert.Contains(t, out, `href="/proj/releases/tag/v1.2.0"`)
431+ } else {
432+ assert.NotContains(t, out, `repo-release`)
433+ }
434+ })
435+ }
436+ out := renderRow(t, HomeRow{Name: "bare"})
437+ assert.NotContains(t, out, `repo-license`)
438+ assert.NotContains(t, out, `repo-release`)
439+}
440+
441+func TestReleaseEventsRefreshTheHomeRow(t *testing.T) {
442+ t.Parallel()
443+ repo := publicRepo(t, "proj")
444+ rel := domain.Release{Repo: repo.Name, Tag: "v2", PublishedAt: fixedNow}
445+ for _, tc := range []struct {
446+ event domain.Event
447+ latest domain.Release
448+ err error
449+ }{
450+ {domain.ReleasePublished{Release: rel}, rel, nil},
451+ {domain.ReleaseUpdated{Release: rel}, domain.Release{Tag: "v1", PublishedAt: fixedNow}, nil},
452+ {domain.ReleaseDeleted{Release: rel}, domain.Release{}, domain.ErrNotFound},
453+ {domain.ReleaseUnpublished{Release: rel}, domain.Release{}, domain.ErrNotFound},
454+ } {
455+ t.Run(tc.event.EventKind(), func(t *testing.T) {
456+ t.Parallel()
457+ releases := mocks.NewReleaseService(t)
458+ releases.EXPECT().GetRelease(mock.Anything, mock.Anything, repo.Name, ports.ReleaseRef{Latest: true}).Return(tc.latest, tc.err).Once()
459+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t), Releases: releases})
460+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: tc.event})
461+ require.NoError(t, err)
462+ require.Len(t, got, 1)
463+ assert.Equal(t, "home-list", got[0].event)
464+ assert.Contains(t, got[0].html, `id="repo-proj"`)
465+ assert.NotContains(t, got[0].html, `data-activity`)
466+ assert.NotContains(t, got[0].html, `new activity`)
467+ if tc.err != nil {
468+ assert.NotContains(t, got[0].html, `repo-release`)
469+ } else {
470+ assert.Contains(t, got[0].html, `href="/proj/releases/tag/`+tc.latest.Tag+`"`)
471+ }
472+ })
473+ }
474+}
M internal/adapters/web/jstest/a11y.test.mjs +2 -1
@@ -339,7 +339,8 @@ test('the preference is read from storage when the page loads', async () => {
339339 });
340340
341341
342-test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
342+test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => {
343+ t.mock.timers.enable({ apis: ['setTimeout'] });
343344 for (const [sort, desc, activity, moves] of [
344345 ['activity', 'true', true, true],
345346 ['activity', 'false', true, false],
@@ -339,7 +339,8 @@ test('the preference is read from storage when the page loads', async () => {
339 });339 });
340 340
341 341
342-test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {342+test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => {
343+ t.mock.timers.enable({ apis: ['setTimeout'] });
343 for (const [sort, desc, activity, moves] of [344 for (const [sort, desc, activity, moves] of [
344 ['activity', 'true', true, true],345 ['activity', 'true', true, true],
345 ['activity', 'false', true, false],346 ['activity', 'false', true, false],
M internal/adapters/web/jstest/live.test.mjs +49 -0
@@ -79,3 +79,52 @@ test('replacement stream URL carries the last connection id, including id-only e
7979 assert.equal(url.searchParams.get('topic'), 'repo:demo');
8080 } finally { globalThis.location = saved; }
8181 });
82+
83+test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => {
84+ const { live } = setup(t);
85+ let current = null, next = null;
86+ let prepends = 0;
87+ const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) {
88+ prepends++;
89+ current = el;
90+ el.parentElement = this;
91+ } };
92+ const fresh = () => {
93+ const attrs = new Set(['data-activity']);
94+ const pill = { removed: false, remove() { this.removed = true; } };
95+ return { id: 'repo-demo', pill, parentElement: rows,
96+ querySelectorAll: () => [],
97+ hasAttribute: (name) => attrs.has(name),
98+ removeAttribute: (name) => attrs.delete(name),
99+ querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null,
100+ replaceWith(el) { current = el; el.parentElement = rows; },
101+ };
102+ };
103+ document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } });
104+ document.getElementById = (id) => id === 'repo-rows' ? rows : current;
105+ document.querySelectorAll = () => [];
106+
107+ next = fresh();
108+ live.swap('first');
109+ const first = current;
110+ assert.equal(prepends, 1, 'a newly added repository is prepended');
111+ t.mock.timers.tick(30000);
112+ next = fresh();
113+ live.swap('second');
114+ const second = current;
115+ assert.equal(prepends, 2, 'later activity reorders the existing row');
116+ t.mock.timers.tick(30000);
117+ assert.equal(first.pill.removed, true);
118+ assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge');
119+ t.mock.timers.tick(29999);
120+ assert.equal(second.pill.removed, false);
121+ t.mock.timers.tick(1);
122+ assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree');
123+ assert.equal(second.hasAttribute('data-activity'), false);
124+ next = fresh();
125+ live.swap('third');
126+ assert.equal(prepends, 3, 'activity still reorders after the previous badge expired');
127+ assert.equal(current.pill.removed, false);
128+ t.mock.timers.tick(60000);
129+ assert.equal(current.pill.removed, true);
130+});
@@ -79,3 +79,52 @@ test('replacement stream URL carries the last connection id, including id-only e
79 assert.equal(url.searchParams.get('topic'), 'repo:demo');79 assert.equal(url.searchParams.get('topic'), 'repo:demo');
80 } finally { globalThis.location = saved; }80 } finally { globalThis.location = saved; }
81 });81 });
82+
83+test('fresh home rows expire after a minute; later swaps still prepend and get a full minute', (t) => {
84+ const { live } = setup(t);
85+ let current = null, next = null;
86+ let prepends = 0;
87+ const rows = { dataset: { sort: 'activity', desc: 'true' }, prepend(el) {
88+ prepends++;
89+ current = el;
90+ el.parentElement = this;
91+ } };
92+ const fresh = () => {
93+ const attrs = new Set(['data-activity']);
94+ const pill = { removed: false, remove() { this.removed = true; } };
95+ return { id: 'repo-demo', pill, parentElement: rows,
96+ querySelectorAll: () => [],
97+ hasAttribute: (name) => attrs.has(name),
98+ removeAttribute: (name) => attrs.delete(name),
99+ querySelector: (sel) => sel === '.repo-activity' && !pill.removed ? pill : null,
100+ replaceWith(el) { current = el; el.parentElement = rows; },
101+ };
102+ };
103+ document.createElement = () => ({ set innerHTML(_) {}, content: { get children() { return [next]; } } });
104+ document.getElementById = (id) => id === 'repo-rows' ? rows : current;
105+ document.querySelectorAll = () => [];
106+
107+ next = fresh();
108+ live.swap('first');
109+ const first = current;
110+ assert.equal(prepends, 1, 'a newly added repository is prepended');
111+ t.mock.timers.tick(30000);
112+ next = fresh();
113+ live.swap('second');
114+ const second = current;
115+ assert.equal(prepends, 2, 'later activity reorders the existing row');
116+ t.mock.timers.tick(30000);
117+ assert.equal(first.pill.removed, true);
118+ assert.equal(second.pill.removed, false, 'the old timer cannot remove the new badge');
119+ t.mock.timers.tick(29999);
120+ assert.equal(second.pill.removed, false);
121+ t.mock.timers.tick(1);
122+ assert.equal(second.pill.removed, true, 'removal also removes it from the accessibility tree');
123+ assert.equal(second.hasAttribute('data-activity'), false);
124+ next = fresh();
125+ live.swap('third');
126+ assert.equal(prepends, 3, 'activity still reorders after the previous badge expired');
127+ assert.equal(current.pill.removed, false);
128+ t.mock.timers.tick(60000);
129+ assert.equal(current.pill.removed, true);
130+});
M internal/adapters/web/jstest/rail.test.mjs +20 -0
@@ -62,3 +62,23 @@ test('h outside the panel leaves the focus alone; j / k outside it are not taken
6262 assert.equal(active, main);
6363 assert.equal(R.step(1), false, 'the row cursor keeps j / k');
6464 });
65+
66+test('j / k cross section and recent-commit table boundaries in document order', () => {
67+ const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } }));
68+ for (const e of controls) e.focus = () => { active = e; };
69+ rail.append(
70+ el('section', {}, controls[0]),
71+ el('section', {}, controls[1]),
72+ el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))),
73+ el('section', {}, controls[4]),
74+ el('nav', {}, controls[5]),
75+ );
76+ active = close;
77+ for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); }
78+ R.step(1);
79+ assert.equal(active, controls.at(-1));
80+ for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); }
81+ R.leave();
82+ assert.equal(active, main);
83+ assert.equal(R.step(1), false);
84+});
@@ -62,3 +62,23 @@ test('h outside the panel leaves the focus alone; j / k outside it are not taken
62 assert.equal(active, main);62 assert.equal(active, main);
63 assert.equal(R.step(1), false, 'the row cursor keeps j / k');63 assert.equal(R.step(1), false, 'the row cursor keeps j / k');
64 });64 });
65+
66+test('j / k cross section and recent-commit table boundaries in document order', () => {
67+ const controls = ['clone', 'about', 'commit-1', 'commit-2', 'actions', 'outline'].map((id) => el('a', { id, attrs: { href: '#' + id } }));
68+ for (const e of controls) e.focus = () => { active = e; };
69+ rail.append(
70+ el('section', {}, controls[0]),
71+ el('section', {}, controls[1]),
72+ el('section', {}, el('table', {}, el('tbody', { attrs: { 'data-rows': '' } }, el('tr', {}, controls[2]), el('tr', {}, controls[3])))),
73+ el('section', {}, controls[4]),
74+ el('nav', {}, controls[5]),
75+ );
76+ active = close;
77+ for (const e of controls) { assert.equal(R.step(1), true); assert.equal(active, e); }
78+ R.step(1);
79+ assert.equal(active, controls.at(-1));
80+ for (const e of controls.slice(0, -1).reverse()) { R.step(-1); assert.equal(active, e); }
81+ R.leave();
82+ assert.equal(active, main);
83+ assert.equal(R.step(1), false);
84+});
A internal/adapters/web/jstest/summary-navigation.test.mjs +47 -0
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import assert from 'node:assert/strict';
4+import { readFileSync } from 'node:fs';
5+import { test } from 'node:test';
6+import { runInNewContext } from 'node:vm';
7+import { el, installDom } from './dom.mjs';
8+
9+const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
10+
11+test('main navigation ignores rail rows, but still drives main tables', () => {
12+ const doc = installDom();
13+ const main = el('main', { id: 'main' });
14+ const recent = el('tr');
15+ const rail = el('aside', { cls: 'rail' }, el('tbody', { attrs: { 'data-rows': '' } }, recent));
16+ doc.body.append(main, rail);
17+ const scrolls = [];
18+ main.scrollBy = (_, y) => scrolls.push(['main-by', y]);
19+ main.scrollTo = (_, y) => scrolls.push(['main-to', y]);
20+ const context = {
21+ window: { ermiteRail: { step: () => false } },
22+ $: (s) => doc.querySelector(s),
23+ $$: (s) => {
24+ const child = s.startsWith('[data-rows] > ') ? s.slice('[data-rows] > '.length) : null;
25+ if (child) return [...doc.querySelectorAll('[data-rows]')].flatMap((table) => table.children.filter((row) => row.matches(child)));
26+ return [...doc.querySelectorAll(s)];
27+ },
28+ body: () => doc.body,
29+ flash() {}, setTimeout() {},
30+ scrollBy: (_, y) => scrolls.push(['page-by', y]),
31+ scrollTo: (_, y) => scrolls.push(['page-to', y]),
32+ };
33+ runInNewContext(source.slice(source.indexOf(' const visibleRows ='), source.indexOf(' window.ermite = ermite;')) + '\nwindow.ermite = ermite;', context);
34+ const E = context.window.ermite;
35+ E.move(1); E.move(-1); E.edge(false); E.edge(true);
36+ assert.deepEqual(scrolls, [['page-by', 180], ['main-by', 180], ['page-by', -180], ['main-by', -180], ['page-to', 0], ['main-to', 0], ['page-to', 1e9], ['main-to', 1e9]]);
37+ assert.equal(recent.hasAttribute('data-cursor'), false);
38+ const first = el('tr');
39+ const last = el('tr');
40+ main.append(el('tbody', { attrs: { 'data-rows': '' } }, first, last));
41+ E.move(1);
42+ assert.equal(first.hasAttribute('data-cursor'), true);
43+ E.edge(true);
44+ assert.equal(last.hasAttribute('data-cursor'), true);
45+ assert.equal(first.hasAttribute('data-cursor'), false);
46+ assert.equal(recent.hasAttribute('data-cursor'), false);
47+});
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import assert from 'node:assert/strict';
4+import { readFileSync } from 'node:fs';
5+import { test } from 'node:test';
6+import { runInNewContext } from 'node:vm';
7+import { el, installDom } from './dom.mjs';
8+
9+const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
10+
11+test('main navigation ignores rail rows, but still drives main tables', () => {
12+ const doc = installDom();
13+ const main = el('main', { id: 'main' });
14+ const recent = el('tr');
15+ const rail = el('aside', { cls: 'rail' }, el('tbody', { attrs: { 'data-rows': '' } }, recent));
16+ doc.body.append(main, rail);
17+ const scrolls = [];
18+ main.scrollBy = (_, y) => scrolls.push(['main-by', y]);
19+ main.scrollTo = (_, y) => scrolls.push(['main-to', y]);
20+ const context = {
21+ window: { ermiteRail: { step: () => false } },
22+ $: (s) => doc.querySelector(s),
23+ $$: (s) => {
24+ const child = s.startsWith('[data-rows] > ') ? s.slice('[data-rows] > '.length) : null;
25+ if (child) return [...doc.querySelectorAll('[data-rows]')].flatMap((table) => table.children.filter((row) => row.matches(child)));
26+ return [...doc.querySelectorAll(s)];
27+ },
28+ body: () => doc.body,
29+ flash() {}, setTimeout() {},
30+ scrollBy: (_, y) => scrolls.push(['page-by', y]),
31+ scrollTo: (_, y) => scrolls.push(['page-to', y]),
32+ };
33+ runInNewContext(source.slice(source.indexOf(' const visibleRows ='), source.indexOf(' window.ermite = ermite;')) + '\nwindow.ermite = ermite;', context);
34+ const E = context.window.ermite;
35+ E.move(1); E.move(-1); E.edge(false); E.edge(true);
36+ assert.deepEqual(scrolls, [['page-by', 180], ['main-by', 180], ['page-by', -180], ['main-by', -180], ['page-to', 0], ['main-to', 0], ['page-to', 1e9], ['main-to', 1e9]]);
37+ assert.equal(recent.hasAttribute('data-cursor'), false);
38+ const first = el('tr');
39+ const last = el('tr');
40+ main.append(el('tbody', { attrs: { 'data-rows': '' } }, first, last));
41+ E.move(1);
42+ assert.equal(first.hasAttribute('data-cursor'), true);
43+ E.edge(true);
44+ assert.equal(last.hasAttribute('data-cursor'), true);
45+ assert.equal(first.hasAttribute('data-cursor'), false);
46+ assert.equal(recent.hasAttribute('data-cursor'), false);
47+});
A internal/adapters/web/license.go +115 -0
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "strings"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// LicenseBadge names the root license file and its display identifier.
16+type LicenseBadge struct {
17+ ID, Href string
18+}
19+
20+var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"}
21+
22+// repositoryLicense caches both absence and detection by immutable head. The
23+// caller has already authorized repository browsing. Failures are not cached.
24+// A separate instance of the bounded immutable-result cache coalesces misses.
25+func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge {
26+ key := repo.String() + "\x00" + head
27+ body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) {
28+ badge, err := s.detectLicense(ctx, repo, head)
29+ if err != nil {
30+ return nil, err
31+ }
32+ return json.Marshal(badge)
33+ })
34+ if err != nil {
35+ log := zerowrap.FromCtx(ctx)
36+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license")
37+ return nil
38+ }
39+ var badge *LicenseBadge
40+ if err := json.Unmarshal(body, &badge); err != nil {
41+ return nil
42+ }
43+ return badge
44+}
45+
46+//hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only
47+func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) {
48+ tree, err := s.getTree(ctx, repo, head, "", false)
49+ if err != nil {
50+ return nil, err
51+ }
52+ for _, name := range licenseNames {
53+ for _, entry := range tree.Entries {
54+ if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) {
55+ continue
56+ }
57+ body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10)
58+ if err != nil {
59+ return nil, err
60+ }
61+ id := "Other"
62+ if !binary {
63+ id = licenseID(string(body))
64+ }
65+ return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil
66+ }
67+ }
68+ return nil, nil
69+}
70+
71+// licenseID deliberately recognizes titles and characteristic grants, not
72+// incidental mentions of other licenses in their full text.
73+func licenseID(body string) string {
74+ for _, line := range strings.Split(body, "\n") {
75+ if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 {
76+ id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):])
77+ id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/"))
78+ if id != "" && len(id) <= 160 {
79+ return id
80+ }
81+ }
82+ }
83+ text := strings.ToLower(strings.Join(strings.Fields(body), " "))
84+ title := text[:min(len(text), 600)]
85+ switch {
86+ case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"):
87+ return "AGPL-3.0"
88+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"):
89+ return "LGPL-3.0"
90+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"):
91+ return "LGPL-2.1"
92+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"):
93+ return "GPL-3.0"
94+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"):
95+ return "GPL-2.0"
96+ case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"):
97+ return "Apache-2.0"
98+ case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"):
99+ return "MPL-2.0"
100+ case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""):
101+ if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") {
102+ return "MIT"
103+ }
104+ return "MIT-0"
105+ case strings.Contains(text, "all advertising materials"):
106+ return "Other"
107+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"):
108+ return "BSD-3-Clause"
109+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"):
110+ return "BSD-2-Clause"
111+ case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"):
112+ return "ISC"
113+ }
114+ return "Other"
115+}
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "strings"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// LicenseBadge names the root license file and its display identifier.
16+type LicenseBadge struct {
17+ ID, Href string
18+}
19+
20+var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"}
21+
22+// repositoryLicense caches both absence and detection by immutable head. The
23+// caller has already authorized repository browsing. Failures are not cached.
24+// A separate instance of the bounded immutable-result cache coalesces misses.
25+func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge {
26+ key := repo.String() + "\x00" + head
27+ body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) {
28+ badge, err := s.detectLicense(ctx, repo, head)
29+ if err != nil {
30+ return nil, err
31+ }
32+ return json.Marshal(badge)
33+ })
34+ if err != nil {
35+ log := zerowrap.FromCtx(ctx)
36+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license")
37+ return nil
38+ }
39+ var badge *LicenseBadge
40+ if err := json.Unmarshal(body, &badge); err != nil {
41+ return nil
42+ }
43+ return badge
44+}
45+
46+//hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only
47+func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) {
48+ tree, err := s.getTree(ctx, repo, head, "", false)
49+ if err != nil {
50+ return nil, err
51+ }
52+ for _, name := range licenseNames {
53+ for _, entry := range tree.Entries {
54+ if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) {
55+ continue
56+ }
57+ body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10)
58+ if err != nil {
59+ return nil, err
60+ }
61+ id := "Other"
62+ if !binary {
63+ id = licenseID(string(body))
64+ }
65+ return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil
66+ }
67+ }
68+ return nil, nil
69+}
70+
71+// licenseID deliberately recognizes titles and characteristic grants, not
72+// incidental mentions of other licenses in their full text.
73+func licenseID(body string) string {
74+ for _, line := range strings.Split(body, "\n") {
75+ if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 {
76+ id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):])
77+ id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/"))
78+ if id != "" && len(id) <= 160 {
79+ return id
80+ }
81+ }
82+ }
83+ text := strings.ToLower(strings.Join(strings.Fields(body), " "))
84+ title := text[:min(len(text), 600)]
85+ switch {
86+ case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"):
87+ return "AGPL-3.0"
88+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"):
89+ return "LGPL-3.0"
90+ case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"):
91+ return "LGPL-2.1"
92+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"):
93+ return "GPL-3.0"
94+ case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"):
95+ return "GPL-2.0"
96+ case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"):
97+ return "Apache-2.0"
98+ case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"):
99+ return "MPL-2.0"
100+ case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""):
101+ if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") {
102+ return "MIT"
103+ }
104+ return "MIT-0"
105+ case strings.Contains(text, "all advertising materials"):
106+ return "Other"
107+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"):
108+ return "BSD-3-Clause"
109+ case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"):
110+ return "BSD-2-Clause"
111+ case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"):
112+ return "ISC"
113+ }
114+ return "Other"
115+}
A internal/adapters/web/license_test.go +188 -0
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func TestLicenseID(t *testing.T) {
22+ t.Parallel()
23+ for _, tc := range []struct{ text, want string }{
24+ {"// SPDX-License-Identifier: MIT", "MIT"},
25+ {"<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->", "Apache-2.0 OR MIT"},
26+ {"/* SPDX-License-Identifier: GPL-3.0-or-later */", "GPL-3.0-or-later"},
27+ {"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007", "AGPL-3.0"},
28+ {"GNU GENERAL PUBLIC LICENSE Version 3" + strings.Repeat(" padding", 100) + "GNU Affero General Public License version 3", "GPL-3.0"},
29+ {"GNU GENERAL PUBLIC LICENSE Version 2", "GPL-2.0"},
30+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 2.1", "LGPL-2.1"},
31+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 3", "LGPL-3.0"},
32+ {"Apache License\nVersion 2.0, January 2004", "Apache-2.0"},
33+ {"Mozilla Public License Version 2.0", "MPL-2.0"},
34+ {"Permission is hereby granted, free of charge\nTHE SOFTWARE IS PROVIDED \"AS IS\"", "MIT-0"},
35+ {"Redistribution and use in source and binary forms ... neither the name", "BSD-3-Clause"},
36+ {"Redistribution and use in source and binary forms ... this list of conditions", "BSD-2-Clause"},
37+ {"Redistribution and use in source and binary forms ... all advertising materials ... neither the name", "Other"},
38+ {"Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee", "ISC"},
39+ {"Custom license mentioning MIT or Apache-2.0", "Other"},
40+ {"", "Other"},
41+ } {
42+ t.Run(tc.want+tc.text[:min(20, len(tc.text))], func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
43+ }
44+}
45+
46+func TestRepositoryLicenseCache(t *testing.T) {
47+ t.Parallel()
48+ for _, filename := range append(append([]string{}, licenseNames...), "LiCeNsE.MD", "missing", "directory") {
49+ t.Run(filename, func(t *testing.T) {
50+ t.Parallel()
51+ browse := mocks.NewBrowseService(t)
52+ repo := repoName(t, "proj")
53+ head := strings.Repeat("a", 40)
54+ entry := domain.TreeEntry{Name: filename, Path: filename, Kind: domain.EntryFile}
55+ if filename == "directory" {
56+ entry.Name, entry.Kind = "LICENSE", domain.EntryDirectory
57+ }
58+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{entry}}, nil).Once()
59+ found := filename != "missing" && filename != "directory"
60+ if found {
61+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("SPDX-License-Identifier: MIT")), nil).Once()
62+ }
63+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
64+ for range 2 {
65+ badge := srv.repositoryLicense(t.Context(), repo, head)
66+ if !found {
67+ assert.Nil(t, badge)
68+ continue
69+ }
70+ require.NotNil(t, badge)
71+ assert.Equal(t, "MIT", badge.ID)
72+ assert.Equal(t, "/proj/blob/"+head+"/"+filename, badge.Href)
73+ }
74+ })
75+ }
76+}
77+
78+func TestRepositoryLicenseRetriesErrorsAndNewHeads(t *testing.T) {
79+ t.Parallel()
80+ browse := mocks.NewBrowseService(t)
81+ repo := repoName(t, "proj")
82+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, errors.New("unavailable")).Once()
83+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Twice()
84+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
85+ for _, head := range []string{strings.Repeat("a", 40), strings.Repeat("a", 40), strings.Repeat("b", 40)} {
86+ assert.Nil(t, srv.repositoryLicense(context.Background(), repo, head))
87+ }
88+}
89+
90+func TestSummaryLicense(t *testing.T) {
91+ t.Parallel()
92+ assert.NotContains(t, renderRail(t, SummaryPage{}), "<dt>license</dt>")
93+ out := renderRail(t, SummaryPage{License: &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}})
94+ assert.Contains(t, out, `<dt>license</dt><dd><a href="/proj/blob/main/LICENSE">MIT</a></dd>`)
95+}
96+
97+func TestHomeLicenseFromDefaultHead(t *testing.T) {
98+ t.Parallel()
99+ for _, binary := range []bool{false, true} {
100+ name := "unknown text"
101+ if binary {
102+ name = "binary"
103+ }
104+ t.Run(name, func(t *testing.T) {
105+ t.Parallel()
106+ browse := mocks.NewBrowseService(t)
107+ repo := pushedRepo(t, "proj")
108+ commits := homeCommits(t, 1)
109+ head := commits[0].ID.String()
110+ revision, err := domain.ParseRevision(head)
111+ require.NoError(t, err)
112+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo.Name, mock.Anything).Return(ports.HistoryPage{Commits: commits}, nil).Once()
113+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo.Name, commits[0].ID, mock.Anything).Return(ports.RepoStats{}, nil).Once()
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{{Name: "COPYING", Path: "COPYING", Kind: domain.EntryFile}}}, nil).Once()
115+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything).Return(domain.BlobInfo{Binary: binary}, io.NopCloser(strings.NewReader("A custom license")), nil).Once()
116+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: browse})
117+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
118+ require.NotNil(t, row.License)
119+ assert.Equal(t, "Other", row.License.ID)
120+ assert.Contains(t, renderRow(t, row), `href="/proj/blob/`+head+`/COPYING" title="License: Other">Other</a>`)
121+ assert.Contains(t, renderRail(t, SummaryPage{License: row.License}), `<dt>license</dt><dd><a href="/proj/blob/`+head+`/COPYING">Other</a></dd>`)
122+ })
123+ }
124+}
125+
126+func TestLicenseFullTexts(t *testing.T) {
127+ t.Parallel()
128+ const grant = `Copyright (c) 2026 Example
129+
130+Permission is hereby granted, free of charge, to any person obtaining a copy
131+of this software and associated documentation files (the "Software"), to deal
132+in the Software without restriction, including without limitation the rights
133+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
134+copies of the Software, and to permit persons to whom the Software is
135+furnished to do so.`
136+ const notice = `The above copyright notice and this permission notice shall be included in
137+all copies or substantial portions of the Software.`
138+ const warranty = `THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
139+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
140+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
141+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
142+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
143+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
144+THE SOFTWARE.`
145+ const bsd = `Copyright (c) 2026 Example. All rights reserved.
146+Redistribution and use in source and binary forms, with or without
147+modification, are permitted provided that the following conditions are met:
148+1. Redistributions of source code must retain the above copyright notice,
149+ this list of conditions and the following disclaimer.
150+2. Redistributions in binary form must reproduce the above copyright notice,
151+ this list of conditions and the following disclaimer in the documentation
152+ and/or other materials provided with the distribution.`
153+ const endorsement = `3. Neither the name of the copyright holder nor the names of its
154+ contributors may be used to endorse or promote products derived from
155+ this software without specific prior written permission.`
156+ const disclaimer = `THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
157+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
158+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
159+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
160+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
161+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
162+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
163+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
164+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
165+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
166+POSSIBILITY OF SUCH DAMAGE.`
167+ const gnu = `Copyright (C) 2007 Free Software Foundation, Inc.
168+Everyone is permitted to copy and distribute verbatim copies of this license
169+document, but changing it is not allowed.
170+
171+Preamble
172+The licenses for most software and other practical works are designed to
173+take away your freedom to share and change the works. By contrast, the GNU
174+General Public License is intended to guarantee your freedom to share and
175+change all versions of a program--to make sure it remains free software
176+for all its users.`
177+ for _, tc := range []struct{ name, text, want string }{
178+ {"MIT", "MIT License\n" + grant + "\n" + notice + "\n" + warranty, "MIT"},
179+ {"MIT-0", "MIT No Attribution\n" + grant + "\n" + warranty, "MIT-0"},
180+ {"BSD-2", bsd + "\n" + disclaimer, "BSD-2-Clause"},
181+ {"BSD-3", bsd + "\n" + endorsement + "\n" + disclaimer, "BSD-3-Clause"},
182+ {"GPL", "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + strings.Repeat("\nTerms and conditions for copying, distribution and modification.", 10) + "\nUse with the GNU Affero General Public License version 3.", "GPL-3.0"},
183+ {"LGPL", "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + "\nThis version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by additional permissions.", "LGPL-3.0"},
184+ {"AGPL", "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n" + gnu + "\nNotwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network an opportunity to receive the Corresponding Source.", "AGPL-3.0"},
185+ } {
186+ t.Run(tc.name, func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
187+ }
188+}
@@ -0,0 +1,188 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func TestLicenseID(t *testing.T) {
22+ t.Parallel()
23+ for _, tc := range []struct{ text, want string }{
24+ {"// SPDX-License-Identifier: MIT", "MIT"},
25+ {"<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->", "Apache-2.0 OR MIT"},
26+ {"/* SPDX-License-Identifier: GPL-3.0-or-later */", "GPL-3.0-or-later"},
27+ {"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007", "AGPL-3.0"},
28+ {"GNU GENERAL PUBLIC LICENSE Version 3" + strings.Repeat(" padding", 100) + "GNU Affero General Public License version 3", "GPL-3.0"},
29+ {"GNU GENERAL PUBLIC LICENSE Version 2", "GPL-2.0"},
30+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 2.1", "LGPL-2.1"},
31+ {"GNU LESSER GENERAL PUBLIC LICENSE Version 3", "LGPL-3.0"},
32+ {"Apache License\nVersion 2.0, January 2004", "Apache-2.0"},
33+ {"Mozilla Public License Version 2.0", "MPL-2.0"},
34+ {"Permission is hereby granted, free of charge\nTHE SOFTWARE IS PROVIDED \"AS IS\"", "MIT-0"},
35+ {"Redistribution and use in source and binary forms ... neither the name", "BSD-3-Clause"},
36+ {"Redistribution and use in source and binary forms ... this list of conditions", "BSD-2-Clause"},
37+ {"Redistribution and use in source and binary forms ... all advertising materials ... neither the name", "Other"},
38+ {"Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee", "ISC"},
39+ {"Custom license mentioning MIT or Apache-2.0", "Other"},
40+ {"", "Other"},
41+ } {
42+ t.Run(tc.want+tc.text[:min(20, len(tc.text))], func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
43+ }
44+}
45+
46+func TestRepositoryLicenseCache(t *testing.T) {
47+ t.Parallel()
48+ for _, filename := range append(append([]string{}, licenseNames...), "LiCeNsE.MD", "missing", "directory") {
49+ t.Run(filename, func(t *testing.T) {
50+ t.Parallel()
51+ browse := mocks.NewBrowseService(t)
52+ repo := repoName(t, "proj")
53+ head := strings.Repeat("a", 40)
54+ entry := domain.TreeEntry{Name: filename, Path: filename, Kind: domain.EntryFile}
55+ if filename == "directory" {
56+ entry.Name, entry.Kind = "LICENSE", domain.EntryDirectory
57+ }
58+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{entry}}, nil).Once()
59+ found := filename != "missing" && filename != "directory"
60+ if found {
61+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("SPDX-License-Identifier: MIT")), nil).Once()
62+ }
63+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
64+ for range 2 {
65+ badge := srv.repositoryLicense(t.Context(), repo, head)
66+ if !found {
67+ assert.Nil(t, badge)
68+ continue
69+ }
70+ require.NotNil(t, badge)
71+ assert.Equal(t, "MIT", badge.ID)
72+ assert.Equal(t, "/proj/blob/"+head+"/"+filename, badge.Href)
73+ }
74+ })
75+ }
76+}
77+
78+func TestRepositoryLicenseRetriesErrorsAndNewHeads(t *testing.T) {
79+ t.Parallel()
80+ browse := mocks.NewBrowseService(t)
81+ repo := repoName(t, "proj")
82+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, errors.New("unavailable")).Once()
83+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil).Twice()
84+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse})
85+ for _, head := range []string{strings.Repeat("a", 40), strings.Repeat("a", 40), strings.Repeat("b", 40)} {
86+ assert.Nil(t, srv.repositoryLicense(context.Background(), repo, head))
87+ }
88+}
89+
90+func TestSummaryLicense(t *testing.T) {
91+ t.Parallel()
92+ assert.NotContains(t, renderRail(t, SummaryPage{}), "<dt>license</dt>")
93+ out := renderRail(t, SummaryPage{License: &LicenseBadge{ID: "MIT", Href: "/proj/blob/main/LICENSE"}})
94+ assert.Contains(t, out, `<dt>license</dt><dd><a href="/proj/blob/main/LICENSE">MIT</a></dd>`)
95+}
96+
97+func TestHomeLicenseFromDefaultHead(t *testing.T) {
98+ t.Parallel()
99+ for _, binary := range []bool{false, true} {
100+ name := "unknown text"
101+ if binary {
102+ name = "binary"
103+ }
104+ t.Run(name, func(t *testing.T) {
105+ t.Parallel()
106+ browse := mocks.NewBrowseService(t)
107+ repo := pushedRepo(t, "proj")
108+ commits := homeCommits(t, 1)
109+ head := commits[0].ID.String()
110+ revision, err := domain.ParseRevision(head)
111+ require.NoError(t, err)
112+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo.Name, mock.Anything).Return(ports.HistoryPage{Commits: commits}, nil).Once()
113+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo.Name, commits[0].ID, mock.Anything).Return(ports.RepoStats{}, nil).Once()
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything, false).Return(domain.Tree{Entries: []domain.TreeEntry{{Name: "COPYING", Path: "COPYING", Kind: domain.EntryFile}}}, nil).Once()
115+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo.Name, revision, mock.Anything).Return(domain.BlobInfo{Binary: binary}, io.NopCloser(strings.NewReader("A custom license")), nil).Once()
116+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: browse})
117+ row := srv.homeRowOf(t.Context(), repo, fixedNow, false)
118+ require.NotNil(t, row.License)
119+ assert.Equal(t, "Other", row.License.ID)
120+ assert.Contains(t, renderRow(t, row), `href="/proj/blob/`+head+`/COPYING" title="License: Other">Other</a>`)
121+ assert.Contains(t, renderRail(t, SummaryPage{License: row.License}), `<dt>license</dt><dd><a href="/proj/blob/`+head+`/COPYING">Other</a></dd>`)
122+ })
123+ }
124+}
125+
126+func TestLicenseFullTexts(t *testing.T) {
127+ t.Parallel()
128+ const grant = `Copyright (c) 2026 Example
129+
130+Permission is hereby granted, free of charge, to any person obtaining a copy
131+of this software and associated documentation files (the "Software"), to deal
132+in the Software without restriction, including without limitation the rights
133+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
134+copies of the Software, and to permit persons to whom the Software is
135+furnished to do so.`
136+ const notice = `The above copyright notice and this permission notice shall be included in
137+all copies or substantial portions of the Software.`
138+ const warranty = `THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
139+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
140+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
141+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
142+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
143+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
144+THE SOFTWARE.`
145+ const bsd = `Copyright (c) 2026 Example. All rights reserved.
146+Redistribution and use in source and binary forms, with or without
147+modification, are permitted provided that the following conditions are met:
148+1. Redistributions of source code must retain the above copyright notice,
149+ this list of conditions and the following disclaimer.
150+2. Redistributions in binary form must reproduce the above copyright notice,
151+ this list of conditions and the following disclaimer in the documentation
152+ and/or other materials provided with the distribution.`
153+ const endorsement = `3. Neither the name of the copyright holder nor the names of its
154+ contributors may be used to endorse or promote products derived from
155+ this software without specific prior written permission.`
156+ const disclaimer = `THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
157+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
158+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
159+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
160+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
161+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
162+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
163+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
164+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
165+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
166+POSSIBILITY OF SUCH DAMAGE.`
167+ const gnu = `Copyright (C) 2007 Free Software Foundation, Inc.
168+Everyone is permitted to copy and distribute verbatim copies of this license
169+document, but changing it is not allowed.
170+
171+Preamble
172+The licenses for most software and other practical works are designed to
173+take away your freedom to share and change the works. By contrast, the GNU
174+General Public License is intended to guarantee your freedom to share and
175+change all versions of a program--to make sure it remains free software
176+for all its users.`
177+ for _, tc := range []struct{ name, text, want string }{
178+ {"MIT", "MIT License\n" + grant + "\n" + notice + "\n" + warranty, "MIT"},
179+ {"MIT-0", "MIT No Attribution\n" + grant + "\n" + warranty, "MIT-0"},
180+ {"BSD-2", bsd + "\n" + disclaimer, "BSD-2-Clause"},
181+ {"BSD-3", bsd + "\n" + endorsement + "\n" + disclaimer, "BSD-3-Clause"},
182+ {"GPL", "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + strings.Repeat("\nTerms and conditions for copying, distribution and modification.", 10) + "\nUse with the GNU Affero General Public License version 3.", "GPL-3.0"},
183+ {"LGPL", "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\n" + gnu + "\nThis version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by additional permissions.", "LGPL-3.0"},
184+ {"AGPL", "GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n" + gnu + "\nNotwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network an opportunity to receive the Corresponding Source.", "AGPL-3.0"},
185+ } {
186+ t.Run(tc.name, func(t *testing.T) { t.Parallel(); assert.Equal(t, tc.want, licenseID(tc.text)) })
187+ }
188+}
M internal/adapters/web/pages.templ +5 -1
@@ -69,7 +69,7 @@ templ Home(l Layout, p HomePage) {
6969 templ Summary(l Layout, p SummaryPage) {
7070 @Shell(l, summaryRail(p)) {
7171 if p.Readme != nil {
72- @ReadmeCard(*p.Readme)
72+ @ReadmeCard(*p.Readme, p.Docs...)
7373 }
7474 }
7575 }
@@ -112,6 +112,10 @@ templ summaryRail(p SummaryPage) {
112112 <dt>{ f.Key }</dt>
113113 @FactValue(f)
114114 }
115+ if lic := p.License; lic != nil {
116+ <dt>license</dt>
117+ <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd>
118+ }
115119 if p.Latest != nil {
116120 @latestTagFact(*p.Latest)
117121 }
@@ -69,7 +69,7 @@ templ Home(l Layout, p HomePage) {
69 templ Summary(l Layout, p SummaryPage) {69 templ Summary(l Layout, p SummaryPage) {
70 @Shell(l, summaryRail(p)) {70 @Shell(l, summaryRail(p)) {
71 if p.Readme != nil {71 if p.Readme != nil {
72- @ReadmeCard(*p.Readme)72+ @ReadmeCard(*p.Readme, p.Docs...)
73 }73 }
74 }74 }
75 }75 }
@@ -112,6 +112,10 @@ templ summaryRail(p SummaryPage) {
112 <dt>{ f.Key }</dt>112 <dt>{ f.Key }</dt>
113 @FactValue(f)113 @FactValue(f)
114 }114 }
115+ if lic := p.License; lic != nil {
116+ <dt>license</dt>
117+ <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd>
118+ }
115 if p.Latest != nil {119 if p.Latest != nil {
116 @latestTagFact(*p.Latest)120 @latestTagFact(*p.Latest)
117 }121 }
M internal/adapters/web/pages_templ.go +331 -299 generated file: collapsed
M internal/adapters/web/pathcache.go +16 -6
@@ -70,6 +70,14 @@ func (c *pathCache) getLocked(id string) ([]byte, bool) {
7070 return el.Value.(*pathEntry).body, true
7171 }
7272
73+// entryOverhead is what an entry costs besides its id and body: the list
74+// element, the entry and the map slot. Counted so that tiny bodies (a cached
75+// "no license") cannot pile up far past the budget.
76+const entryOverhead = 128
77+
78+// cost is what an entry counts against the budget.
79+func (e *pathEntry) cost() int { return len(e.id) + len(e.body) + entryOverhead }
80+
7381 // putLocked stores a list, evicting the least recently used ones to stay in
7482 // budget. A list over a quarter of the budget is not stored.
7583 func (c *pathCache) putLocked(id string, body []byte) {
@@ -77,20 +85,22 @@ func (c *pathCache) putLocked(id string, body []byte) {
7785 return
7886 }
7987 if el, ok := c.byID[id]; ok {
80- c.size -= len(el.Value.(*pathEntry).body)
81- el.Value.(*pathEntry).body = body
82- c.size += len(body)
88+ e := el.Value.(*pathEntry)
89+ c.size -= e.cost()
90+ e.body = body
91+ c.size += e.cost()
8392 c.lru.MoveToFront(el)
8493 } else {
85- c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})
86- c.size += len(body)
94+ e := &pathEntry{id: id, body: body}
95+ c.byID[id] = c.lru.PushFront(e)
96+ c.size += e.cost()
8797 }
8898 for c.size > c.budget {
8999 el := c.lru.Back()
90100 e := el.Value.(*pathEntry)
91101 c.lru.Remove(el)
92102 delete(c.byID, e.id)
93- c.size -= len(e.body)
103+ c.size -= e.cost()
94104 }
95105 }
96106
@@ -70,6 +70,14 @@ func (c *pathCache) getLocked(id string) ([]byte, bool) {
70 return el.Value.(*pathEntry).body, true70 return el.Value.(*pathEntry).body, true
71 }71 }
72 72
73+// entryOverhead is what an entry costs besides its id and body: the list
74+// element, the entry and the map slot. Counted so that tiny bodies (a cached
75+// "no license") cannot pile up far past the budget.
76+const entryOverhead = 128
77+
78+// cost is what an entry counts against the budget.
79+func (e *pathEntry) cost() int { return len(e.id) + len(e.body) + entryOverhead }
80+
73 // putLocked stores a list, evicting the least recently used ones to stay in81 // putLocked stores a list, evicting the least recently used ones to stay in
74 // budget. A list over a quarter of the budget is not stored.82 // budget. A list over a quarter of the budget is not stored.
75 func (c *pathCache) putLocked(id string, body []byte) {83 func (c *pathCache) putLocked(id string, body []byte) {
@@ -77,20 +85,22 @@ func (c *pathCache) putLocked(id string, body []byte) {
77 return85 return
78 }86 }
79 if el, ok := c.byID[id]; ok {87 if el, ok := c.byID[id]; ok {
80- c.size -= len(el.Value.(*pathEntry).body)88+ e := el.Value.(*pathEntry)
81- el.Value.(*pathEntry).body = body89+ c.size -= e.cost()
82- c.size += len(body)90+ e.body = body
91+ c.size += e.cost()
83 c.lru.MoveToFront(el)92 c.lru.MoveToFront(el)
84 } else {93 } else {
85- c.byID[id] = c.lru.PushFront(&pathEntry{id: id, body: body})94+ e := &pathEntry{id: id, body: body}
86- c.size += len(body)95+ c.byID[id] = c.lru.PushFront(e)
96+ c.size += e.cost()
87 }97 }
88 for c.size > c.budget {98 for c.size > c.budget {
89 el := c.lru.Back()99 el := c.lru.Back()
90 e := el.Value.(*pathEntry)100 e := el.Value.(*pathEntry)
91 c.lru.Remove(el)101 c.lru.Remove(el)
92 delete(c.byID, e.id)102 delete(c.byID, e.id)
93- c.size -= len(e.body)103+ c.size -= e.cost()
94 }104 }
95 }105 }
96 106
M internal/adapters/web/pathcache_test.go +34 -17
@@ -30,55 +30,72 @@ func (c *pathCache) has(id string) bool {
3030 return ok
3131 }
3232
33+// entry is the budget an entry of a one-letter id and an n-byte body takes.
34+func entry(n int) int { return 1 + n + entryOverhead }
35+
3336 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
3437 t.Parallel()
35- c := newPathCache(100, 1)
38+ budget := 4 * entry(100)
39+ c := newPathCache(budget, 1)
3640 blob := func(n int) []byte { return make([]byte, n) }
37- c.store("a", blob(25))
38- c.store("b", blob(25))
39- c.store("e", blob(25))
41+ c.store("a", blob(100))
42+ c.store("b", blob(100))
43+ c.store("e", blob(100))
4044 require.True(t, c.has("a")) // a is now the most recent
41- c.store("c", blob(25)) // 100 fit
42- c.store("d", blob(10)) // 110 > 100: b, the least recent, goes
45+ c.store("c", blob(100)) // four fit
46+ c.store("d", blob(10)) // one more: b, the least recent, goes
4347 assert.False(t, c.has("b"), "b was evicted")
4448 assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))
45- assert.Equal(t, 85, c.size)
49+ assert.Equal(t, 3*entry(100)+entry(10), c.size)
4650
4751 // Replacing an entry accounts for the old size.
4852 c.store("a", blob(10))
49- assert.Equal(t, 70, c.size)
53+ assert.Equal(t, 2*entry(100)+2*entry(10), c.size)
5054
5155 // A big one pushes out as many as needed.
52- c.store("f", blob(25))
53- c.store("g", blob(25))
54- assert.LessOrEqual(t, c.size, 100)
56+ c.store("f", blob(100))
57+ c.store("g", blob(100))
58+ assert.LessOrEqual(t, c.size, budget)
5559 assert.True(t, c.has("g"))
5660 }
5761
62+// Tiny bodies (a cached "no license") count their id and overhead too, so
63+// their number stays bounded by the budget.
64+func TestPathCacheBoundsTinyEntries(t *testing.T) {
65+ t.Parallel()
66+ c := newPathCache(1<<12, 1)
67+ for i := range 10_000 {
68+ c.store(fmt.Sprintf("repo\x00%040d", i), []byte("null"))
69+ }
70+ assert.LessOrEqual(t, c.size, 1<<12)
71+ assert.Less(t, len(c.byID), (1<<12)/entryOverhead+1)
72+ assert.Equal(t, len(c.byID), c.lru.Len())
73+}
74+
5875 // A list over a quarter of the budget is not kept, since it would push out the
5976 // others; one at the limit is.
6077 func TestPathCacheSkipsOversizedEntries(t *testing.T) {
6178 t.Parallel()
62- c := newPathCache(100, 1)
79+ c := newPathCache(1000, 1)
6380 var builds atomic.Int32
6481 big := func(n int) func() ([]byte, error) {
6582 return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }
6683 }
6784 for range 2 {
68- b, err := c.load(context.Background(), "r", "big", big(26))
85+ b, err := c.load(context.Background(), "r", "big", big(251))
6986 require.NoError(t, err)
70- assert.Len(t, b, 26, "the caller still gets the list")
87+ assert.Len(t, b, 251, "the caller still gets the list")
7188 }
7289 assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")
7390 assert.False(t, c.has("big"))
7491 assert.Zero(t, c.size)
7592
7693 for range 2 {
77- _, err := c.load(context.Background(), "r", "fits", big(25))
94+ _, err := c.load(context.Background(), "r", "fits", big(250))
7895 require.NoError(t, err)
7996 }
8097 assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")
81- assert.Equal(t, 25, c.size)
98+ assert.Equal(t, len("fits")+250+entryOverhead, c.size)
8299 }
83100
84101 func TestPathCacheConcurrent(t *testing.T) {
@@ -99,7 +116,7 @@ func TestPathCacheConcurrent(t *testing.T) {
99116 assert.LessOrEqual(t, c.size, 1000)
100117 sum := 0
101118 for _, el := range c.byID {
102- sum += len(el.Value.(*pathEntry).body)
119+ sum += el.Value.(*pathEntry).cost()
103120 }
104121 assert.Equal(t, sum, c.size, "the size is the sum of the entries")
105122 assert.Equal(t, len(c.byID), c.lru.Len())
@@ -30,55 +30,72 @@ func (c *pathCache) has(id string) bool {
30 return ok30 return ok
31 }31 }
32 32
33+// entry is the budget an entry of a one-letter id and an n-byte body takes.
34+func entry(n int) int { return 1 + n + entryOverhead }
35+
33 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {36 func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) {
34 t.Parallel()37 t.Parallel()
35- c := newPathCache(100, 1)38+ budget := 4 * entry(100)
39+ c := newPathCache(budget, 1)
36 blob := func(n int) []byte { return make([]byte, n) }40 blob := func(n int) []byte { return make([]byte, n) }
37- c.store("a", blob(25))41+ c.store("a", blob(100))
38- c.store("b", blob(25))42+ c.store("b", blob(100))
39- c.store("e", blob(25))43+ c.store("e", blob(100))
40 require.True(t, c.has("a")) // a is now the most recent44 require.True(t, c.has("a")) // a is now the most recent
41- c.store("c", blob(25)) // 100 fit45+ c.store("c", blob(100)) // four fit
42- c.store("d", blob(10)) // 110 > 100: b, the least recent, goes46+ c.store("d", blob(10)) // one more: b, the least recent, goes
43 assert.False(t, c.has("b"), "b was evicted")47 assert.False(t, c.has("b"), "b was evicted")
44 assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))48 assert.True(t, c.has("a") && c.has("c") && c.has("d") && c.has("e"))
45- assert.Equal(t, 85, c.size)49+ assert.Equal(t, 3*entry(100)+entry(10), c.size)
46 50
47 // Replacing an entry accounts for the old size.51 // Replacing an entry accounts for the old size.
48 c.store("a", blob(10))52 c.store("a", blob(10))
49- assert.Equal(t, 70, c.size)53+ assert.Equal(t, 2*entry(100)+2*entry(10), c.size)
50 54
51 // A big one pushes out as many as needed.55 // A big one pushes out as many as needed.
52- c.store("f", blob(25))56+ c.store("f", blob(100))
53- c.store("g", blob(25))57+ c.store("g", blob(100))
54- assert.LessOrEqual(t, c.size, 100)58+ assert.LessOrEqual(t, c.size, budget)
55 assert.True(t, c.has("g"))59 assert.True(t, c.has("g"))
56 }60 }
57 61
62+// Tiny bodies (a cached "no license") count their id and overhead too, so
63+// their number stays bounded by the budget.
64+func TestPathCacheBoundsTinyEntries(t *testing.T) {
65+ t.Parallel()
66+ c := newPathCache(1<<12, 1)
67+ for i := range 10_000 {
68+ c.store(fmt.Sprintf("repo\x00%040d", i), []byte("null"))
69+ }
70+ assert.LessOrEqual(t, c.size, 1<<12)
71+ assert.Less(t, len(c.byID), (1<<12)/entryOverhead+1)
72+ assert.Equal(t, len(c.byID), c.lru.Len())
73+}
74+
58 // A list over a quarter of the budget is not kept, since it would push out the75 // A list over a quarter of the budget is not kept, since it would push out the
59 // others; one at the limit is.76 // others; one at the limit is.
60 func TestPathCacheSkipsOversizedEntries(t *testing.T) {77 func TestPathCacheSkipsOversizedEntries(t *testing.T) {
61 t.Parallel()78 t.Parallel()
62- c := newPathCache(100, 1)79+ c := newPathCache(1000, 1)
63 var builds atomic.Int3280 var builds atomic.Int32
64 big := func(n int) func() ([]byte, error) {81 big := func(n int) func() ([]byte, error) {
65 return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }82 return func() ([]byte, error) { builds.Add(1); return make([]byte, n), nil }
66 }83 }
67 for range 2 {84 for range 2 {
68- b, err := c.load(context.Background(), "r", "big", big(26))85+ b, err := c.load(context.Background(), "r", "big", big(251))
69 require.NoError(t, err)86 require.NoError(t, err)
70- assert.Len(t, b, 26, "the caller still gets the list")87+ assert.Len(t, b, 251, "the caller still gets the list")
71 }88 }
72 assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")89 assert.EqualValues(t, 2, builds.Load(), "not cached, so built again")
73 assert.False(t, c.has("big"))90 assert.False(t, c.has("big"))
74 assert.Zero(t, c.size)91 assert.Zero(t, c.size)
75 92
76 for range 2 {93 for range 2 {
77- _, err := c.load(context.Background(), "r", "fits", big(25))94+ _, err := c.load(context.Background(), "r", "fits", big(250))
78 require.NoError(t, err)95 require.NoError(t, err)
79 }96 }
80 assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")97 assert.EqualValues(t, 3, builds.Load(), "at a quarter: kept")
81- assert.Equal(t, 25, c.size)98+ assert.Equal(t, len("fits")+250+entryOverhead, c.size)
82 }99 }
83 100
84 func TestPathCacheConcurrent(t *testing.T) {101 func TestPathCacheConcurrent(t *testing.T) {
@@ -99,7 +116,7 @@ func TestPathCacheConcurrent(t *testing.T) {
99 assert.LessOrEqual(t, c.size, 1000)116 assert.LessOrEqual(t, c.size, 1000)
100 sum := 0117 sum := 0
101 for _, el := range c.byID {118 for _, el := range c.byID {
102- sum += len(el.Value.(*pathEntry).body)119+ sum += el.Value.(*pathEntry).cost()
103 }120 }
104 assert.Equal(t, sum, c.size, "the size is the sum of the entries")121 assert.Equal(t, sum, c.size, "the size is the sum of the entries")
105 assert.Equal(t, len(c.byID), c.lru.Len())122 assert.Equal(t, len(c.byID), c.lru.Len())
M internal/adapters/web/static/css/00-tokens.css +1 -0
@@ -78,6 +78,7 @@
7878 --fs-code: 13px;
7979 --fs-s: 13px;
8080 --fs-xs: 12px;
81+ --fs-2xs: 11px;
8182 --lh: 24px;
8283 --lh-code: 20px;
8384
@@ -78,6 +78,7 @@
78 --fs-code: 13px;78 --fs-code: 13px;
79 --fs-s: 13px;79 --fs-s: 13px;
80 --fs-xs: 12px;80 --fs-xs: 12px;
81+ --fs-2xs: 11px;
81 --lh: 24px;82 --lh: 24px;
82 --lh-code: 20px;83 --lh-code: 20px;
83 84
M internal/adapters/web/static/css/20-shell.css +6 -2
@@ -29,15 +29,19 @@
2929 .i-cards { --i: url(icons/cards.svg); }
3030 .i-split { --i: url(icons/split.svg); }
3131 .i-compact { --i: url(icons/compact.svg); }
32+ .i-refresh-cw { --i: url(icons/refresh-cw.svg); }
3233 .i-mailbox { --i: url(icons/mailbox.svg); }
3334 .i-sun-moon { --i: url(icons/sun-moon.svg); }
3435 .i-sun { --i: url(icons/sun.svg); }
3536 .i-moon { --i: url(icons/moon.svg); }
3637
3738 .pill {
38- display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
39- font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
39+ display: inline-flex; align-items: center; height: 18px; padding: 0 var(--s2); margin-left: var(--s1);
40+ font: 600 var(--fs-2xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
4041 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
42+ /* a link pill: no underline; the border lightens on hover and focus */
43+ &:is(a) { text-decoration: none; cursor: pointer; transition: border-color .12s; }
44+ &:is(a):hover, &:is(a):focus-visible { text-decoration: none; border-color: var(--ink-dim); }
4145 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
4246 &.new { color: var(--teal); border-color: currentColor; }
4347 /* the signature of a commit: the protocol, then the verdict, always as text
@@ -29,15 +29,19 @@
29 .i-cards { --i: url(icons/cards.svg); }29 .i-cards { --i: url(icons/cards.svg); }
30 .i-split { --i: url(icons/split.svg); }30 .i-split { --i: url(icons/split.svg); }
31 .i-compact { --i: url(icons/compact.svg); }31 .i-compact { --i: url(icons/compact.svg); }
32+ .i-refresh-cw { --i: url(icons/refresh-cw.svg); }
32 .i-mailbox { --i: url(icons/mailbox.svg); }33 .i-mailbox { --i: url(icons/mailbox.svg); }
33 .i-sun-moon { --i: url(icons/sun-moon.svg); }34 .i-sun-moon { --i: url(icons/sun-moon.svg); }
34 .i-sun { --i: url(icons/sun.svg); }35 .i-sun { --i: url(icons/sun.svg); }
35 .i-moon { --i: url(icons/moon.svg); }36 .i-moon { --i: url(icons/moon.svg); }
36 37
37 .pill {38 .pill {
38- display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);39+ display: inline-flex; align-items: center; height: 18px; padding: 0 var(--s2); margin-left: var(--s1);
39- font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;40+ font: 600 var(--fs-2xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
40 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;41 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
42+ /* a link pill: no underline; the border lightens on hover and focus */
43+ &:is(a) { text-decoration: none; cursor: pointer; transition: border-color .12s; }
44+ &:is(a):hover, &:is(a):focus-visible { text-decoration: none; border-color: var(--ink-dim); }
41 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }45 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
42 &.new { color: var(--teal); border-color: currentColor; }46 &.new { color: var(--teal); border-color: currentColor; }
43 /* the signature of a commit: the protocol, then the verdict, always as text47 /* the signature of a commit: the protocol, then the verdict, always as text
M internal/adapters/web/static/css/30-list.css +10 -10
@@ -69,8 +69,12 @@
6969 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
7070 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
7171 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
72- & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
72+ & .repo:hover .repo-name a:not(.pill), & .repo[data-cursor] .repo-name a:not(.pill) { text-decoration: underline; }
7373 & .repo-name .pill { margin-left: 0; }
74+ & .repo-name a.pill { display: block; line-height: 18px; min-width: 0; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; }
75+ & .repo-name .repo-release { color: var(--accent); }
76+ & .repo-name .repo-license { color: var(--ink-dim); }
77+ & .repo-activity { display: inline-flex; align-items: center; flex: none; color: var(--accent); }
7478 /* Every card has the same height: two lines of description, one of topics
7579 (reserved when there are none), five of commits. */
7680 & .repo-desc {
@@ -123,7 +127,7 @@
123127 & .repo-stats dd a { color: inherit; text-decoration: none; }
124128 & .repo-stats dd a:hover { text-decoration: underline; }
125129 & .repo-ci {
126- flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;
130+ flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px;
127131 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
128132 }
129133 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
@@ -170,7 +174,7 @@
170174 }
171175 & .repo-main, & .repo-head { display: contents; }
172176 & .repo-name { grid-area: name; align-self: center; font-size: 16px; }
173- & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; }
177+ & .repo-ci { grid-area: ci; align-self: center; }
174178 & .repo-desc { grid-area: desc; margin-top: var(--s2); }
175179 & .repo-topics { grid-area: topics; margin-top: var(--s2); }
176180 & .repo-topics:empty { display: none; }
@@ -212,7 +216,8 @@
212216 /* min-width 0: a long name is cut, it does not widen the column past its cap */
213217 & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
214218 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
215- & .repo-name .pill { flex: none; }
219+ & .repo-name .pill { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
220+ & .repo-name a.pill { max-width: 12ch; }
216221 & .repo-desc {
217222 grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
218223 font-size: var(--fs-s); color: var(--ink-mute);
@@ -227,13 +232,8 @@
227232 position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
228233 }
229234 & .repo-stats .rs-tag dd { max-width: 10ch; }
230- & .repo-stats .rs-tag dd::before {
231- content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
232- background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
233- }
234235 /* the CI state is its mark; the word is in the tooltip and for screen readers */
235- & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; }
236- & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
236+ & .repo-ci { grid-area: ci; justify-self: center; }
237237 /* the pulse: the same curve, small, with a bigger dot to stay seen */
238238 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
239239 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
@@ -69,8 +69,12 @@
69 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }69 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
70 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }70 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
71 & .repo-name a { color: var(--ink-hi); text-decoration: none; }71 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
72- & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }72+ & .repo:hover .repo-name a:not(.pill), & .repo[data-cursor] .repo-name a:not(.pill) { text-decoration: underline; }
73 & .repo-name .pill { margin-left: 0; }73 & .repo-name .pill { margin-left: 0; }
74+ & .repo-name a.pill { display: block; line-height: 18px; min-width: 0; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: normal; }
75+ & .repo-name .repo-release { color: var(--accent); }
76+ & .repo-name .repo-license { color: var(--ink-dim); }
77+ & .repo-activity { display: inline-flex; align-items: center; flex: none; color: var(--accent); }
74 /* Every card has the same height: two lines of description, one of topics78 /* Every card has the same height: two lines of description, one of topics
75 (reserved when there are none), five of commits. */79 (reserved when there are none), five of commits. */
76 & .repo-desc {80 & .repo-desc {
@@ -123,7 +127,7 @@
123 & .repo-stats dd a { color: inherit; text-decoration: none; }127 & .repo-stats dd a { color: inherit; text-decoration: none; }
124 & .repo-stats dd a:hover { text-decoration: underline; }128 & .repo-stats dd a:hover { text-decoration: underline; }
125 & .repo-ci {129 & .repo-ci {
126- flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;130+ flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px;
127 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;131 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
128 }132 }
129 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }133 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
@@ -170,7 +174,7 @@
170 }174 }
171 & .repo-main, & .repo-head { display: contents; }175 & .repo-main, & .repo-head { display: contents; }
172 & .repo-name { grid-area: name; align-self: center; font-size: 16px; }176 & .repo-name { grid-area: name; align-self: center; font-size: 16px; }
173- & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; }177+ & .repo-ci { grid-area: ci; align-self: center; }
174 & .repo-desc { grid-area: desc; margin-top: var(--s2); }178 & .repo-desc { grid-area: desc; margin-top: var(--s2); }
175 & .repo-topics { grid-area: topics; margin-top: var(--s2); }179 & .repo-topics { grid-area: topics; margin-top: var(--s2); }
176 & .repo-topics:empty { display: none; }180 & .repo-topics:empty { display: none; }
@@ -212,7 +216,8 @@
212 /* min-width 0: a long name is cut, it does not widen the column past its cap */216 /* min-width 0: a long name is cut, it does not widen the column past its cap */
213 & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }217 & .repo-name { grid-area: name; min-width: 0; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
214 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }218 & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
215- & .repo-name .pill { flex: none; }219+ & .repo-name .pill { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
220+ & .repo-name a.pill { max-width: 12ch; }
216 & .repo-desc {221 & .repo-desc {
217 grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;222 grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
218 font-size: var(--fs-s); color: var(--ink-mute);223 font-size: var(--fs-s); color: var(--ink-mute);
@@ -227,13 +232,8 @@
227 position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);232 position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
228 }233 }
229 & .repo-stats .rs-tag dd { max-width: 10ch; }234 & .repo-stats .rs-tag dd { max-width: 10ch; }
230- & .repo-stats .rs-tag dd::before {
231- content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
232- background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
233- }
234 /* the CI state is its mark; the word is in the tooltip and for screen readers */235 /* the CI state is its mark; the word is in the tooltip and for screen readers */
235- & .repo-ci { grid-area: ci; justify-self: center; padding-top: 0; }236+ & .repo-ci { grid-area: ci; justify-self: center; }
236- & .repo-ci .ci-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
237 /* the pulse: the same curve, small, with a bigger dot to stay seen */237 /* the pulse: the same curve, small, with a bigger dot to stay seen */
238 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }238 & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
239 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }239 & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
M internal/adapters/web/static/css/40-summary.css +5 -0
@@ -13,6 +13,11 @@
1313 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
1414
1515 .readme .prose { padding: var(--s5) var(--s6) var(--s6); }
16+ .doc-tabs {
17+ display: flex; flex-wrap: wrap; gap: var(--s4);
18+ & a { color: var(--ink-dim); }
19+ & a[aria-current] { color: var(--teal); text-decoration: underline; text-underline-offset: 5px; }
20+ }
1621
1722 /* rendered Markdown */
1823 .prose {
@@ -13,6 +13,11 @@
13 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }13 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
14 14
15 .readme .prose { padding: var(--s5) var(--s6) var(--s6); }15 .readme .prose { padding: var(--s5) var(--s6) var(--s6); }
16+ .doc-tabs {
17+ display: flex; flex-wrap: wrap; gap: var(--s4);
18+ & a { color: var(--ink-dim); }
19+ & a[aria-current] { color: var(--teal); text-decoration: underline; text-underline-offset: 5px; }
20+ }
16 21
17 /* rendered Markdown */22 /* rendered Markdown */
18 .prose {23 .prose {
A internal/adapters/web/static/icons/refresh-cw.svg +3 -0
@@ -0,0 +1,3 @@
1+<!-- SPDX-License-Identifier: ISC -->
2+<!-- Lucide 1.8: refresh-cw; https://lucide.dev/license -->
3+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
@@ -0,0 +1,3 @@
1+<!-- SPDX-License-Identifier: ISC -->
2+<!-- Lucide 1.8: refresh-cw; https://lucide.dev/license -->
3+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
M internal/adapters/web/static/js/ermite.js +10 -1
@@ -37,7 +37,8 @@
3737 flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
3838 };
3939
40- const visibleRows = () => $$('[data-rows] > :not([hidden])');
40+ // Rail tables are context, not the main row cursor.
41+ const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail'));
4142 // setCursor puts data-cursor on el and takes it from the others.
4243 const setCursor = (el, others) => {
4344 for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
@@ -1007,6 +1008,14 @@ window.ermiteLive = (() => {
10071008 const cursor = document.querySelector('[data-rows] > [data-cursor]');
10081009 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
10091010 for (const el of [...t.content.children]) {
1011+ // Each fresh fragment owns its timer; an older swap cannot expire it.
1012+ // No animation, including for visitors who prefer reduced motion.
1013+ if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) {
1014+ setTimeout(() => {
1015+ el.querySelector('.repo-activity')?.remove();
1016+ el.removeAttribute('data-activity');
1017+ }, 60000);
1018+ }
10101019 const old = el.id && document.getElementById(el.id);
10111020 const had = old ? where(old) : null; // the focus is inside what is replaced
10121021 if (el.hasAttribute('data-gone')) {
@@ -37,7 +37,8 @@
37 flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);37 flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
38 };38 };
39 39
40- const visibleRows = () => $$('[data-rows] > :not([hidden])');40+ // Rail tables are context, not the main row cursor.
41+ const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail'));
41 // setCursor puts data-cursor on el and takes it from the others.42 // setCursor puts data-cursor on el and takes it from the others.
42 const setCursor = (el, others) => {43 const setCursor = (el, others) => {
43 for (const o of others) if (o !== el) o.removeAttribute('data-cursor');44 for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
@@ -1007,6 +1008,14 @@ window.ermiteLive = (() => {
1007 const cursor = document.querySelector('[data-rows] > [data-cursor]');1008 const cursor = document.querySelector('[data-rows] > [data-cursor]');
1008 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');1009 const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
1009 for (const el of [...t.content.children]) {1010 for (const el of [...t.content.children]) {
1011+ // Each fresh fragment owns its timer; an older swap cannot expire it.
1012+ // No animation, including for visitors who prefer reduced motion.
1013+ if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) {
1014+ setTimeout(() => {
1015+ el.querySelector('.repo-activity')?.remove();
1016+ el.removeAttribute('data-activity');
1017+ }, 60000);
1018+ }
1010 const old = el.id && document.getElementById(el.id);1019 const old = el.id && document.getElementById(el.id);
1011 const had = old ? where(old) : null; // the focus is inside what is replaced1020 const had = old ? where(old) : null; // the focus is inside what is replaced
1012 if (el.hasAttribute('data-gone')) {1021 if (el.hasAttribute('data-gone')) {
M internal/adapters/web/summary.go +53 -29
@@ -10,6 +10,7 @@ import (
1010 "html/template"
1111 "io"
1212 "net/http"
13+ "net/url"
1314 "strings"
1415 "time"
1516
@@ -59,22 +60,23 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
5960 return nil, 0, err
6061 }
6162 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
62- for i, c := range commits.Commits {
63+ for _, c := range commits.Commits {
6364 id := c.ID.String()
6465 row := commitRow(d.name(), c, now)
65- row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0
66+ row.Branches, row.Tags = branchesAt[id], tagsAt[id]
6667 p.Recent = append(p.Recent, row)
6768 }
6869
70+ p.License = s.repositoryLicense(ctx, d.repo.Name, d.head.CommitID.String())
6971 p.Facts = s.facts(d, now)
7072 p.Latest = s.latest(ctx, d)
7173 p.Release = s.summaryRelease(ctx, d.repo.Name, now)
7274 p.Actions = s.recentActions(ctx, d.repo.Name, now)
7375
74- if rd, err := s.readme(ctx, d, ref); err != nil {
76+ if rd, tabs, err := s.summaryDoc(ctx, d, ref, r.URL.Query().Get("doc")); err != nil {
7577 return nil, 0, err
7678 } else if rd != nil {
77- p.Readme, p.Outline = rd.readme, rd.outline
79+ p.Readme, p.Outline, p.Docs = rd.readme, rd.outline, tabs
7880 }
7981
8082 l := s.repoLayout(ctx, d, "summary")
@@ -147,40 +149,62 @@ type renderedReadme struct {
147149 outline []Heading
148150 }
149151
150-// readme finds the README at the root of the tree of the head commit, reads
151-// it and renders it. It is nil when the repository has none. Rendering
152-// problems degrade to plain text; only use case failures are errors.
153-func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
152+// summaryDoc renders only a listed root document; unknown selections use the first.
153+func (s *Server) summaryDoc(ctx context.Context, d *repoData, ref, selected string) (*renderedReadme, []DocTab, error) {
154154 rev := d.head.CommitID.String()
155155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
156156 if err != nil {
157- return nil, err
157+ return nil, nil, err
158+ }
159+ entries := summaryDocs(tree.Entries)
160+ if len(entries) == 0 {
161+ return nil, nil, nil
158162 }
159- return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)
163+ index := 0
164+ for i, e := range entries {
165+ if e.Name == selected {
166+ index = i
167+ break
168+ }
169+ if strings.EqualFold(e.Name, selected) {
170+ index = i
171+ }
172+ }
173+ tabs := make([]DocTab, 0, len(entries))
174+ for i, e := range entries {
175+ tabs = append(tabs, DocTab{Name: e.Name, Href: "?doc=" + url.QueryEscape(e.Name), Current: i == index})
176+ }
177+ rd, err := s.renderDoc(ctx, d.repo.Name, rev, ref, "", entries[index])
178+ return rd, tabs, err
160179 }
161180
162-// readmeIn picks the README among the entries of the directory dir ("" for
163-// the root) of the commit rev, reads it and renders it. Links in it are
164-// relative to dir, on the ref ref. It is nil when the directory has none.
165-//
166-//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
167-func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
168- var best *domain.TreeEntry
169- rank := len(readmeNames)
170- for i := range entries {
171- e := &entries[i]
172- if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
173- continue
174- }
175- for i, n := range readmeNames {
176- if i < rank && strings.EqualFold(e.Name, n) {
177- best, rank = e, i
181+// summaryDocs lists the preferred README, then the common project documents.
182+func summaryDocs(entries []domain.TreeEntry) []domain.TreeEntry {
183+ var docs []domain.TreeEntry
184+ groups := [][]string{readmeNames}
185+ for _, base := range []string{"license", "licence", "copying", "contributing", "code_of_conduct", "security"} {
186+ groups = append(groups, []string{base, base + ".md", base + ".markdown", base + ".txt"})
187+ }
188+ for g, names := range groups {
189+ for _, name := range names {
190+ for _, e := range entries {
191+ if (e.Kind == domain.EntryFile || e.Kind == domain.EntryExecutable) && strings.EqualFold(e.Name, name) {
192+ docs = append(docs, e)
193+ }
194+ }
195+ if g == 0 && len(docs) > 0 {
196+ break
178197 }
179198 }
180199 }
181- if best == nil {
182- return nil, nil
183- }
200+ return docs
201+}
202+
203+// renderDoc shares the README limits and rendering with the summary documents.
204+//
205+//hexcheck:ignore suspicious-business-logic-in-adapter reads and renders a root document to show; display only
206+func (s *Server) renderDoc(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entry domain.TreeEntry) (*renderedReadme, error) {
207+ best := &entry
184208 rd := &Readme{Name: best.Name}
185209 if best.Size > maxReadmeBytes {
186210 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
@@ -10,6 +10,7 @@ import (
10 "html/template"10 "html/template"
11 "io"11 "io"
12 "net/http"12 "net/http"
13+ "net/url"
13 "strings"14 "strings"
14 "time"15 "time"
15 16
@@ -59,22 +60,23 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
59 return nil, 0, err60 return nil, 0, err
60 }61 }
61 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)62 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
62- for i, c := range commits.Commits {63+ for _, c := range commits.Commits {
63 id := c.ID.String()64 id := c.ID.String()
64 row := commitRow(d.name(), c, now)65 row := commitRow(d.name(), c, now)
65- row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 066+ row.Branches, row.Tags = branchesAt[id], tagsAt[id]
66 p.Recent = append(p.Recent, row)67 p.Recent = append(p.Recent, row)
67 }68 }
68 69
70+ p.License = s.repositoryLicense(ctx, d.repo.Name, d.head.CommitID.String())
69 p.Facts = s.facts(d, now)71 p.Facts = s.facts(d, now)
70 p.Latest = s.latest(ctx, d)72 p.Latest = s.latest(ctx, d)
71 p.Release = s.summaryRelease(ctx, d.repo.Name, now)73 p.Release = s.summaryRelease(ctx, d.repo.Name, now)
72 p.Actions = s.recentActions(ctx, d.repo.Name, now)74 p.Actions = s.recentActions(ctx, d.repo.Name, now)
73 75
74- if rd, err := s.readme(ctx, d, ref); err != nil {76+ if rd, tabs, err := s.summaryDoc(ctx, d, ref, r.URL.Query().Get("doc")); err != nil {
75 return nil, 0, err77 return nil, 0, err
76 } else if rd != nil {78 } else if rd != nil {
77- p.Readme, p.Outline = rd.readme, rd.outline79+ p.Readme, p.Outline, p.Docs = rd.readme, rd.outline, tabs
78 }80 }
79 81
80 l := s.repoLayout(ctx, d, "summary")82 l := s.repoLayout(ctx, d, "summary")
@@ -147,40 +149,62 @@ type renderedReadme struct {
147 outline []Heading149 outline []Heading
148 }150 }
149 151
150-// readme finds the README at the root of the tree of the head commit, reads152+// summaryDoc renders only a listed root document; unknown selections use the first.
151-// it and renders it. It is nil when the repository has none. Rendering153+func (s *Server) summaryDoc(ctx context.Context, d *repoData, ref, selected string) (*renderedReadme, []DocTab, error) {
152-// problems degrade to plain text; only use case failures are errors.
153-func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
154 rev := d.head.CommitID.String()154 rev := d.head.CommitID.String()
155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
156 if err != nil {156 if err != nil {
157- return nil, err157+ return nil, nil, err
158+ }
159+ entries := summaryDocs(tree.Entries)
160+ if len(entries) == 0 {
161+ return nil, nil, nil
158 }162 }
159- return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)163+ index := 0
164+ for i, e := range entries {
165+ if e.Name == selected {
166+ index = i
167+ break
168+ }
169+ if strings.EqualFold(e.Name, selected) {
170+ index = i
171+ }
172+ }
173+ tabs := make([]DocTab, 0, len(entries))
174+ for i, e := range entries {
175+ tabs = append(tabs, DocTab{Name: e.Name, Href: "?doc=" + url.QueryEscape(e.Name), Current: i == index})
176+ }
177+ rd, err := s.renderDoc(ctx, d.repo.Name, rev, ref, "", entries[index])
178+ return rd, tabs, err
160 }179 }
161 180
162-// readmeIn picks the README among the entries of the directory dir ("" for181+// summaryDocs lists the preferred README, then the common project documents.
163-// the root) of the commit rev, reads it and renders it. Links in it are182+func summaryDocs(entries []domain.TreeEntry) []domain.TreeEntry {
164-// relative to dir, on the ref ref. It is nil when the directory has none.183+ var docs []domain.TreeEntry
165-//184+ groups := [][]string{readmeNames}
166-//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only185+ for _, base := range []string{"license", "licence", "copying", "contributing", "code_of_conduct", "security"} {
167-func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {186+ groups = append(groups, []string{base, base + ".md", base + ".markdown", base + ".txt"})
168- var best *domain.TreeEntry187+ }
169- rank := len(readmeNames)188+ for g, names := range groups {
170- for i := range entries {189+ for _, name := range names {
171- e := &entries[i]190+ for _, e := range entries {
172- if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {191+ if (e.Kind == domain.EntryFile || e.Kind == domain.EntryExecutable) && strings.EqualFold(e.Name, name) {
173- continue192+ docs = append(docs, e)
174- }193+ }
175- for i, n := range readmeNames {194+ }
176- if i < rank && strings.EqualFold(e.Name, n) {195+ if g == 0 && len(docs) > 0 {
177- best, rank = e, i196+ break
178 }197 }
179 }198 }
180 }199 }
181- if best == nil {200+ return docs
182- return nil, nil201+}
183- }202+
203+// renderDoc shares the README limits and rendering with the summary documents.
204+//
205+//hexcheck:ignore suspicious-business-logic-in-adapter reads and renders a root document to show; display only
206+func (s *Server) renderDoc(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entry domain.TreeEntry) (*renderedReadme, error) {
207+ best := &entry
184 rd := &Readme{Name: best.Name}208 rd := &Readme{Name: best.Name}
185 if best.Size > maxReadmeBytes {209 if best.Size > maxReadmeBytes {
186 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."210 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
A internal/adapters/web/summary_docs_test.go +157 -0
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func TestSummaryDocs(t *testing.T) {
23+ t.Parallel()
24+ entries := []domain.TreeEntry{
25+ {Name: "readme.txt", Kind: domain.EntryFile},
26+ {Name: "SECURITY.md", Kind: domain.EntryFile},
27+ {Name: "LICENSE", Kind: domain.EntryExecutable},
28+ {Name: "README.md", Kind: domain.EntryFile},
29+ {Name: "CONTRIBUTING.md", Kind: domain.EntryDirectory},
30+ {Name: "COPYING.txt", Kind: domain.EntryFile},
31+ {Name: "code_of_conduct.MD", Kind: domain.EntryFile},
32+ }
33+ docs := summaryDocs(entries)
34+ var names []string
35+ for _, e := range docs {
36+ names = append(names, e.Name)
37+ }
38+ assert.Equal(t, []string{"README.md", "LICENSE", "COPYING.txt", "code_of_conduct.MD", "SECURITY.md"}, names)
39+ assert.Empty(t, summaryDocs(nil))
40+}
41+
42+func TestSummaryDocSelection(t *testing.T) {
43+ t.Parallel()
44+ for _, tc := range []struct {
45+ selected, want, body string
46+ markdown, noReadme bool
47+ }{
48+ {"", "README.md", "# Read me", true, false},
49+ {"security.MD", "SECURITY.md", "# Reporting", true, false},
50+ {"LICENSE", "LICENSE", "<license>", false, false},
51+ {"../secret", "README.md", "# Read me", true, false},
52+ {"", "LICENSE", "<license>", false, true},
53+ } {
54+ t.Run(tc.selected+tc.want, func(t *testing.T) {
55+ t.Parallel()
56+ repo := repoName(t, "r")
57+ browse := mocks.NewBrowseService(t)
58+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "SECURITY.md", Path: "SECURITY.md", Kind: domain.EntryFile}}
59+ if !tc.noReadme {
60+ entries = append(entries, domain.TreeEntry{Name: "README.md", Path: "README.md", Kind: domain.EntryFile})
61+ }
62+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
63+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
64+ assert.Equal(t, tc.want, path.String())
65+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader(tc.body)), nil
66+ })
67+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
68+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
69+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", tc.selected)
70+ require.NoError(t, err)
71+ require.NotNil(t, rd)
72+ assert.Equal(t, tc.want, rd.readme.Name)
73+ if tc.markdown {
74+ assert.Contains(t, string(rd.readme.HTML), "<h1")
75+ assert.Len(t, rd.outline, 1)
76+ } else {
77+ assert.Equal(t, "<pre>&lt;license&gt;</pre>", string(rd.readme.HTML))
78+ assert.Empty(t, rd.outline)
79+ }
80+ current := 0
81+ for _, tab := range tabs {
82+ if tab.Current {
83+ current++
84+ assert.Equal(t, tc.want, tab.Name)
85+ }
86+ }
87+ assert.Equal(t, 1, current)
88+ out := renderToString(t, ReadmeCard(*rd.readme, tabs...))
89+ assert.Contains(t, out, `aria-label="Repository documents"`)
90+ assert.Contains(t, out, `aria-current="page"`)
91+ assert.Contains(t, out, `href="?doc=LICENSE"`)
92+ })
93+ }
94+}
95+
96+func TestSummaryDocLimits(t *testing.T) {
97+ t.Parallel()
98+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
99+ rd, err := s.renderDoc(t.Context(), repoName(t, "r"), mainTip, "main", "", domain.TreeEntry{Name: "LICENSE", Size: maxReadmeBytes + 1})
100+ require.NoError(t, err)
101+ assert.Contains(t, rd.readme.Note, "too large")
102+ assert.Empty(t, rd.outline)
103+ out := renderToString(t, ReadmeCard(*rd.readme))
104+ assert.NotContains(t, out, "doc-tabs")
105+}
106+
107+func TestSummaryArrivalHasNoRailCursor(t *testing.T) {
108+ t.Parallel()
109+ repo := repoName(t, "r")
110+ browse := mocks.NewBrowseService(t)
111+ tip := objectID(t, mainTip)
112+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: tip}}}, nil)
113+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: []domain.Commit{{ID: tip, Summary: "recent commit"}}}, nil)
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil)
115+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
116+ req := httptest.NewRequest(http.MethodGet, "/r", nil)
117+ req.SetPathValue("repo", "r")
118+ page, status, err := s.summary(t.Context(), req)
119+ require.NoError(t, err)
120+ assert.Equal(t, http.StatusOK, status)
121+ out := renderToString(t, page)
122+ assert.Contains(t, out, "recent commit")
123+ _, rail, found := strings.Cut(out, `<aside class="rail"`)
124+ require.True(t, found)
125+ rail, _, _ = strings.Cut(rail, "</aside>")
126+ assert.NotContains(t, rail, "data-cursor")
127+ assert.NotContains(t, rail, "autofocus")
128+}
129+
130+func TestSummaryDocExactFilename(t *testing.T) {
131+ t.Parallel()
132+ for _, selected := range []string{"LICENSE", "license"} {
133+ t.Run(selected, func(t *testing.T) {
134+ t.Parallel()
135+ repo := repoName(t, "r")
136+ browse := mocks.NewBrowseService(t)
137+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "license", Path: "license", Kind: domain.EntryFile}}
138+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
139+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
140+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader("content of " + path.String())), nil
141+ })
142+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
143+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
144+ req := httptest.NewRequest(http.MethodGet, "/r?doc="+selected, nil)
145+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", req.URL.Query().Get("doc"))
146+ require.NoError(t, err)
147+ require.NotNil(t, rd)
148+ assert.Equal(t, selected, rd.readme.Name)
149+ assert.Equal(t, "<pre>content of "+selected+"</pre>", string(rd.readme.HTML))
150+ require.Len(t, tabs, 2)
151+ for _, tab := range tabs {
152+ assert.Equal(t, tab.Name == selected, tab.Current, tab.Name)
153+ assert.Equal(t, "?doc="+tab.Name, tab.Href)
154+ }
155+ })
156+ }
157+}
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func TestSummaryDocs(t *testing.T) {
23+ t.Parallel()
24+ entries := []domain.TreeEntry{
25+ {Name: "readme.txt", Kind: domain.EntryFile},
26+ {Name: "SECURITY.md", Kind: domain.EntryFile},
27+ {Name: "LICENSE", Kind: domain.EntryExecutable},
28+ {Name: "README.md", Kind: domain.EntryFile},
29+ {Name: "CONTRIBUTING.md", Kind: domain.EntryDirectory},
30+ {Name: "COPYING.txt", Kind: domain.EntryFile},
31+ {Name: "code_of_conduct.MD", Kind: domain.EntryFile},
32+ }
33+ docs := summaryDocs(entries)
34+ var names []string
35+ for _, e := range docs {
36+ names = append(names, e.Name)
37+ }
38+ assert.Equal(t, []string{"README.md", "LICENSE", "COPYING.txt", "code_of_conduct.MD", "SECURITY.md"}, names)
39+ assert.Empty(t, summaryDocs(nil))
40+}
41+
42+func TestSummaryDocSelection(t *testing.T) {
43+ t.Parallel()
44+ for _, tc := range []struct {
45+ selected, want, body string
46+ markdown, noReadme bool
47+ }{
48+ {"", "README.md", "# Read me", true, false},
49+ {"security.MD", "SECURITY.md", "# Reporting", true, false},
50+ {"LICENSE", "LICENSE", "<license>", false, false},
51+ {"../secret", "README.md", "# Read me", true, false},
52+ {"", "LICENSE", "<license>", false, true},
53+ } {
54+ t.Run(tc.selected+tc.want, func(t *testing.T) {
55+ t.Parallel()
56+ repo := repoName(t, "r")
57+ browse := mocks.NewBrowseService(t)
58+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "SECURITY.md", Path: "SECURITY.md", Kind: domain.EntryFile}}
59+ if !tc.noReadme {
60+ entries = append(entries, domain.TreeEntry{Name: "README.md", Path: "README.md", Kind: domain.EntryFile})
61+ }
62+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
63+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
64+ assert.Equal(t, tc.want, path.String())
65+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader(tc.body)), nil
66+ })
67+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
68+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
69+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", tc.selected)
70+ require.NoError(t, err)
71+ require.NotNil(t, rd)
72+ assert.Equal(t, tc.want, rd.readme.Name)
73+ if tc.markdown {
74+ assert.Contains(t, string(rd.readme.HTML), "<h1")
75+ assert.Len(t, rd.outline, 1)
76+ } else {
77+ assert.Equal(t, "<pre>&lt;license&gt;</pre>", string(rd.readme.HTML))
78+ assert.Empty(t, rd.outline)
79+ }
80+ current := 0
81+ for _, tab := range tabs {
82+ if tab.Current {
83+ current++
84+ assert.Equal(t, tc.want, tab.Name)
85+ }
86+ }
87+ assert.Equal(t, 1, current)
88+ out := renderToString(t, ReadmeCard(*rd.readme, tabs...))
89+ assert.Contains(t, out, `aria-label="Repository documents"`)
90+ assert.Contains(t, out, `aria-current="page"`)
91+ assert.Contains(t, out, `href="?doc=LICENSE"`)
92+ })
93+ }
94+}
95+
96+func TestSummaryDocLimits(t *testing.T) {
97+ t.Parallel()
98+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
99+ rd, err := s.renderDoc(t.Context(), repoName(t, "r"), mainTip, "main", "", domain.TreeEntry{Name: "LICENSE", Size: maxReadmeBytes + 1})
100+ require.NoError(t, err)
101+ assert.Contains(t, rd.readme.Note, "too large")
102+ assert.Empty(t, rd.outline)
103+ out := renderToString(t, ReadmeCard(*rd.readme))
104+ assert.NotContains(t, out, "doc-tabs")
105+}
106+
107+func TestSummaryArrivalHasNoRailCursor(t *testing.T) {
108+ t.Parallel()
109+ repo := repoName(t, "r")
110+ browse := mocks.NewBrowseService(t)
111+ tip := objectID(t, mainTip)
112+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: tip}}}, nil)
113+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: []domain.Commit{{ID: tip, Summary: "recent commit"}}}, nil)
114+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{}, nil)
115+ s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
116+ req := httptest.NewRequest(http.MethodGet, "/r", nil)
117+ req.SetPathValue("repo", "r")
118+ page, status, err := s.summary(t.Context(), req)
119+ require.NoError(t, err)
120+ assert.Equal(t, http.StatusOK, status)
121+ out := renderToString(t, page)
122+ assert.Contains(t, out, "recent commit")
123+ _, rail, found := strings.Cut(out, `<aside class="rail"`)
124+ require.True(t, found)
125+ rail, _, _ = strings.Cut(rail, "</aside>")
126+ assert.NotContains(t, rail, "data-cursor")
127+ assert.NotContains(t, rail, "autofocus")
128+}
129+
130+func TestSummaryDocExactFilename(t *testing.T) {
131+ t.Parallel()
132+ for _, selected := range []string{"LICENSE", "license"} {
133+ t.Run(selected, func(t *testing.T) {
134+ t.Parallel()
135+ repo := repoName(t, "r")
136+ browse := mocks.NewBrowseService(t)
137+ entries := []domain.TreeEntry{{Name: "LICENSE", Path: "LICENSE", Kind: domain.EntryFile}, {Name: "license", Path: "license", Kind: domain.EntryFile}}
138+ browse.EXPECT().GetTree(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, false).Return(domain.Tree{Entries: entries}, nil)
139+ browse.EXPECT().OpenBlob(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
140+ return domain.BlobInfo{}, io.NopCloser(strings.NewReader("content of " + path.String())), nil
141+ })
142+ s := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: browse})
143+ d := &repoData{repo: ports.RepoView{Name: repo}, head: &domain.Ref{CommitID: objectID(t, mainTip)}}
144+ req := httptest.NewRequest(http.MethodGet, "/r?doc="+selected, nil)
145+ rd, tabs, err := s.summaryDoc(t.Context(), d, "main", req.URL.Query().Get("doc"))
146+ require.NoError(t, err)
147+ require.NotNil(t, rd)
148+ assert.Equal(t, selected, rd.readme.Name)
149+ assert.Equal(t, "<pre>content of "+selected+"</pre>", string(rd.readme.HTML))
150+ require.Len(t, tabs, 2)
151+ for _, tab := range tabs {
152+ assert.Equal(t, tab.Name == selected, tab.Current, tab.Name)
153+ assert.Equal(t, "?doc="+tab.Name, tab.Href)
154+ }
155+ })
156+ }
157+}
M internal/adapters/web/view.go +14 -4
@@ -84,10 +84,12 @@ type Link struct{ Label, Href string }
8484
8585 // HomeRow is one repository in the home list.
8686 type HomeRow struct {
87- ID string
88- Name string
89- Href string
90- Desc string
87+ Release *LatestRelease
88+ License *LicenseBadge
89+ ID string
90+ Name string
91+ Href string
92+ Desc string
9193 // Topics classify the repository, as on GitHub.
9294 Topics []string
9395 // Active is when the repository last changed (its last push or merge),
@@ -201,8 +203,16 @@ type CommitRow struct {
201203 Sig *SignatureBadge
202204 }
203205
206+// DocTab links to a root document on the summary.
207+type DocTab struct {
208+ Name, Href string
209+ Current bool
210+}
211+
204212 // SummaryPage is the repository summary.
205213 type SummaryPage struct {
214+ Docs []DocTab
215+ License *LicenseBadge
206216 Mirror *MirrorBadge
207217 Facts []Fact
208218 Clone Clone
@@ -84,10 +84,12 @@ type Link struct{ Label, Href string }
84 84
85 // HomeRow is one repository in the home list.85 // HomeRow is one repository in the home list.
86 type HomeRow struct {86 type HomeRow struct {
87- ID string87+ Release *LatestRelease
88- Name string88+ License *LicenseBadge
89- Href string89+ ID string
90- Desc string90+ Name string
91+ Href string
92+ Desc string
91 // Topics classify the repository, as on GitHub.93 // Topics classify the repository, as on GitHub.
92 Topics []string94 Topics []string
93 // Active is when the repository last changed (its last push or merge),95 // Active is when the repository last changed (its last push or merge),
@@ -201,8 +203,16 @@ type CommitRow struct {
201 Sig *SignatureBadge203 Sig *SignatureBadge
202 }204 }
203 205
206+// DocTab links to a root document on the summary.
207+type DocTab struct {
208+ Name, Href string
209+ Current bool
210+}
211+
204 // SummaryPage is the repository summary.212 // SummaryPage is the repository summary.
205 type SummaryPage struct {213 type SummaryPage struct {
214+ Docs []DocTab
215+ License *LicenseBadge
206 Mirror *MirrorBadge216 Mirror *MirrorBadge
207 Facts []Fact217 Facts []Fact
208 Clone Clone218 Clone Clone
M internal/adapters/web/web.go +3 -0
@@ -139,6 +139,8 @@ type Server struct {
139139 md *markdown
140140 // pathCache holds the path lists the file finder fetches.
141141 pathCache *pathCache
142+ // licenses holds small display results, including missing licenses.
143+ licenses *pathCache
142144 // hl holds the colours of highlighted diffs.
143145 hl *hlCache
144146 mux *http.ServeMux
@@ -174,6 +176,7 @@ func New(cfg Config) (*Server, error) {
174176 s := &Server{
175177 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
176178 pathCache: newPathCache(pathCacheBytes, pathBuilds),
179+ licenses: newPathCache(1<<20, homeWorkers),
177180 hl: newHLCache(hlCacheBytes),
178181 origin: originOf(u),
179182 }
@@ -139,6 +139,8 @@ type Server struct {
139 md *markdown139 md *markdown
140 // pathCache holds the path lists the file finder fetches.140 // pathCache holds the path lists the file finder fetches.
141 pathCache *pathCache141 pathCache *pathCache
142+ // licenses holds small display results, including missing licenses.
143+ licenses *pathCache
142 // hl holds the colours of highlighted diffs.144 // hl holds the colours of highlighted diffs.
143 hl *hlCache145 hl *hlCache
144 mux *http.ServeMux146 mux *http.ServeMux
@@ -174,6 +176,7 @@ func New(cfg Config) (*Server, error) {
174 s := &Server{176 s := &Server{
175 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),177 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
176 pathCache: newPathCache(pathCacheBytes, pathBuilds),178 pathCache: newPathCache(pathCacheBytes, pathBuilds),
179+ licenses: newPathCache(1<<20, homeWorkers),
177 hl: newHLCache(hlCacheBytes),180 hl: newHLCache(hlCacheBytes),
178 origin: originOf(u),181 origin: originOf(u),
179 }182 }
M internal/app/testdata/web/summary.html +2 -2
@@ -99,7 +99,7 @@
9999 <p class="lede">A demo repository</p>
100100 </section>
101101 <main id="main" tabindex="-1">
102-<section class="card readme" aria-labelledby="readme-h">
102+<section class="card readme" aria-label="README.md">
103103 <h2 class="card-h" id="readme-h">
104104 <span class="ico i-file" aria-hidden="true">
105105 </span> README.md</h2>
@@ -164,7 +164,7 @@
164164 </h2>
165165 <table class="list commits" aria-label="Commits">
166166 <tbody data-rows>
167-<tr data-cursor>
167+<tr>
168168 <td class="c-sha mute">SHA7</td>
169169 <td class="c-subject">
170170 <a href="/demo/commit/SHA40">add main.go</a>
@@ -99,7 +99,7 @@
99 <p class="lede">A demo repository</p>99 <p class="lede">A demo repository</p>
100 </section>100 </section>
101 <main id="main" tabindex="-1">101 <main id="main" tabindex="-1">
102-<section class="card readme" aria-labelledby="readme-h">102+<section class="card readme" aria-label="README.md">
103 <h2 class="card-h" id="readme-h">103 <h2 class="card-h" id="readme-h">
104 <span class="ico i-file" aria-hidden="true">104 <span class="ico i-file" aria-hidden="true">
105 </span> README.md</h2>105 </span> README.md</h2>
@@ -164,7 +164,7 @@
164 </h2>164 </h2>
165 <table class="list commits" aria-label="Commits">165 <table class="list commits" aria-label="Commits">
166 <tbody data-rows>166 <tbody data-rows>
167-<tr data-cursor>167+<tr>
168 <td class="c-sha mute">SHA7</td>168 <td class="c-sha mute">SHA7</td>
169 <td class="c-subject">169 <td class="c-subject">
170 <a href="/demo/commit/SHA40">add main.go</a>170 <a href="/demo/commit/SHA40">add main.go</a>

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close