detached e08572c

feat(web): show releases as one card with prose notes and the latest release in About

commit
e08572cdbcb35f78e9849a172ea0dd69ef5105b9
author
brice · · 2026-10-22 05:47 UTC
committer
brice · · 2026-10-22 08:51 UTC
parent
5d69f2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +518 -304
M internal/adapters/web/fragments.go +1 -1
@@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
167167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
168168 switch ev := we.Event.(type) {
169169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
170- if l.view == "releases" || l.view == "release" {
170+ if l.view == "releases" || l.view == "release" || l.view == "summary" {
171171 return gap, nil // notes, assets, latest or visibility changed: reload
172172 }
173173 return nil, nil
@@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
168 switch ev := we.Event.(type) {168 switch ev := we.Event.(type) {
169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
170- if l.view == "releases" || l.view == "release" {170+ if l.view == "releases" || l.view == "release" || l.view == "summary" {
171 return gap, nil // notes, assets, latest or visibility changed: reload171 return gap, nil // notes, assets, latest or visibility changed: reload
172 }172 }
173 return nil, nil173 return nil, nil
M internal/adapters/web/fragments_test.go +3 -3
@@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) {
101101 }
102102 }
103103
104-// Release lifecycle events invalidate the release pages without putting notes
105-// or asset metadata in SSE payloads. Other views need no release fragments.
104+// Release lifecycle events invalidate release pages and the summary's release
105+// fact without putting notes or asset metadata in SSE payloads.
106106 func TestReleaseLiveUpdates(t *testing.T) {
107107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108108 we := ports.WatchEvent{Event: ev}
@@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) {
111111 st := &liveStream{repo: "proj", view: view}
112112 got, err := st.render(t.Context(), we)
113113 require.NoError(t, err)
114- if view == "release" || view == "releases" {
114+ if view == "release" || view == "releases" || view == "summary" {
115115 assert.Equal(t, gap, got)
116116 } else {
117117 assert.Empty(t, got)
@@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) {
101 }101 }
102 }102 }
103 103
104-// Release lifecycle events invalidate the release pages without putting notes104+// Release lifecycle events invalidate release pages and the summary's release
105-// or asset metadata in SSE payloads. Other views need no release fragments.105+// fact without putting notes or asset metadata in SSE payloads.
106 func TestReleaseLiveUpdates(t *testing.T) {106 func TestReleaseLiveUpdates(t *testing.T) {
107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108 we := ports.WatchEvent{Event: ev}108 we := ports.WatchEvent{Event: ev}
@@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) {
111 st := &liveStream{repo: "proj", view: view}111 st := &liveStream{repo: "proj", view: view}
112 got, err := st.render(t.Context(), we)112 got, err := st.render(t.Context(), we)
113 require.NoError(t, err)113 require.NoError(t, err)
114- if view == "release" || view == "releases" {114+ if view == "release" || view == "releases" || view == "summary" {
115 assert.Equal(t, gap, got)115 assert.Equal(t, gap, got)
116 } else {116 } else {
117 assert.Empty(t, got)117 assert.Empty(t, got)
M internal/adapters/web/pages.templ +8 -0
@@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) {
115115 if p.Latest != nil {
116116 @latestTagFact(*p.Latest)
117117 }
118+ if r := p.Release; r != nil {
119+ <dt>release</dt>
120+ <dd>
121+ <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " }
122+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " }
123+ <a href={ templ.SafeURL(r.AllHref) }>all releases</a>
124+ </dd>
125+ }
118126 </dl>
119127 </section>
120128 if len(p.Recent) > 0 {
@@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) {
115 if p.Latest != nil {115 if p.Latest != nil {
116 @latestTagFact(*p.Latest)116 @latestTagFact(*p.Latest)
117 }117 }
118+ if r := p.Release; r != nil {
119+ <dt>release</dt>
120+ <dd>
121+ <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " }
122+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " }
123+ <a href={ templ.SafeURL(r.AllHref) }>all releases</a>
124+ </dd>
125+ }
118 </dl>126 </dl>
119 </section>127 </section>
120 if len(p.Recent) > 0 {128 if len(p.Recent) > 0 {
M internal/adapters/web/pages_templ.go +371 -261 generated file: collapsed
M internal/adapters/web/releases.templ +16 -9
@@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) {
4040
4141 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
4242 @Shell(l, nil) {
43- <h1>{ p.Title }</h1>
44- <p>
45- <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46- if p.SHA != "" {
47- <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48- }
49- <span class="dim">{ p.Published }</span>
50- </p>
51- <div class="md">@templ.Raw(string(p.Notes))</div>
43+ <section class="mr-card">
44+ <header class="show mr-h">
45+ <h1>{ p.Title }</h1>
46+ <dl class="kv show-kv">
47+ <dt>tag</dt>
48+ <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd>
49+ if p.SHA != "" {
50+ <dt>commit</dt>
51+ <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd>
52+ }
53+ <dt>published</dt>
54+ <dd>{ p.Published }</dd>
55+ </dl>
56+ </header>
57+ <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article>
58+ </section>
5259 <h2>Assets</h2>
5360 if len(p.Assets) == 0 {
5461 <p class="dim">no assets.</p>
@@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) {
40 40
41 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {41 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42 @Shell(l, nil) {42 @Shell(l, nil) {
43- <h1>{ p.Title }</h1>43+ <section class="mr-card">
44- <p>44+ <header class="show mr-h">
45- <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)45+ <h1>{ p.Title }</h1>
46- if p.SHA != "" {46+ <dl class="kv show-kv">
47- <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>47+ <dt>tag</dt>
48- }48+ <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd>
49- <span class="dim">{ p.Published }</span>49+ if p.SHA != "" {
50- </p>50+ <dt>commit</dt>
51- <div class="md">@templ.Raw(string(p.Notes))</div>51+ <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd>
52+ }
53+ <dt>published</dt>
54+ <dd>{ p.Published }</dd>
55+ </dl>
56+ </header>
57+ <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article>
58+ </section>
52 <h2>Assets</h2>59 <h2>Assets</h2>
53 if len(p.Assets) == 0 {60 if len(p.Assets) == 0 {
54 <p class="dim">no assets.</p>61 <p class="dim">no assets.</p>
M internal/adapters/web/releases_templ.go +32 -28
@@ -245,27 +245,27 @@ func ReleaseDetail(l Layout, p ReleaseDetailPage) templ.Component {
245245 }()
246246 }
247247 ctx = templ.InitializeContext(ctx)
248- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<h1>")
248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section class=\"mr-card\"><header class=\"show mr-h\"><h1>")
249249 if templ_7745c5c3_Err != nil {
250250 return templ_7745c5c3_Err
251251 }
252252 var templ_7745c5c3_Var12 string
253253 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
254254 if templ_7745c5c3_Err != nil {
255- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 43, Col: 15}
255+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 45, Col: 17}
256256 }
257257 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
258258 if templ_7745c5c3_Err != nil {
259259 return templ_7745c5c3_Err
260260 }
261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><p><span class=\"pill\">")
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><dl class=\"kv show-kv\"><dt>tag</dt><dd><span class=\"pill\">")
262262 if templ_7745c5c3_Err != nil {
263263 return templ_7745c5c3_Err
264264 }
265265 var templ_7745c5c3_Var13 string
266266 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Tag)
267267 if templ_7745c5c3_Err != nil {
268- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 45, Col: 29}
268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 48, Col: 35}
269269 }
270270 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
271271 if templ_7745c5c3_Err != nil {
@@ -279,52 +279,56 @@ func ReleaseDetail(l Layout, p ReleaseDetailPage) templ.Component {
279279 if templ_7745c5c3_Err != nil {
280280 return templ_7745c5c3_Err
281281 }
282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</dd>")
283+ if templ_7745c5c3_Err != nil {
284+ return templ_7745c5c3_Err
285+ }
282286 if p.SHA != "" {
283- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<a href=\"")
287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>commit</dt><dd><a href=\"")
284288 if templ_7745c5c3_Err != nil {
285289 return templ_7745c5c3_Err
286290 }
287291 var templ_7745c5c3_Var14 templ.SafeURL
288292 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CommitHref))
289293 if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 47, Col: 41}
294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 51, Col: 47}
291295 }
292296 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
293297 if templ_7745c5c3_Err != nil {
294298 return templ_7745c5c3_Err
295299 }
296- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><code>")
300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><code>")
297301 if templ_7745c5c3_Err != nil {
298302 return templ_7745c5c3_Err
299303 }
300304 var templ_7745c5c3_Var15 string
301305 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.SHA)
302306 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 47, Col: 57}
307+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 51, Col: 63}
304308 }
305309 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
306310 if templ_7745c5c3_Err != nil {
307311 return templ_7745c5c3_Err
308312 }
309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</code></a> ")
313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</code></a></dd>")
310314 if templ_7745c5c3_Err != nil {
311315 return templ_7745c5c3_Err
312316 }
313317 }
314- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"dim\">")
318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<dt>published</dt><dd>")
315319 if templ_7745c5c3_Err != nil {
316320 return templ_7745c5c3_Err
317321 }
318322 var templ_7745c5c3_Var16 string
319323 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(p.Published)
320324 if templ_7745c5c3_Err != nil {
321- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 49, Col: 34}
325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 54, Col: 22}
322326 }
323327 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
324328 if templ_7745c5c3_Err != nil {
325329 return templ_7745c5c3_Err
326330 }
327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span></p><div class=\"md\">")
331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</dd></dl></header><article class=\"mr-desc prose\">")
328332 if templ_7745c5c3_Err != nil {
329333 return templ_7745c5c3_Err
330334 }
@@ -332,97 +336,97 @@ func ReleaseDetail(l Layout, p ReleaseDetailPage) templ.Component {
332336 if templ_7745c5c3_Err != nil {
333337 return templ_7745c5c3_Err
334338 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><h2>Assets</h2>")
339+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</article></section><h2>Assets</h2>")
336340 if templ_7745c5c3_Err != nil {
337341 return templ_7745c5c3_Err
338342 }
339343 if len(p.Assets) == 0 {
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p class=\"dim\">no assets.</p>")
344+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"dim\">no assets.</p>")
341345 if templ_7745c5c3_Err != nil {
342346 return templ_7745c5c3_Err
343347 }
344348 } else {
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<table class=\"list\" aria-label=\"Release assets\"><thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead> <tbody>")
349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<table class=\"list\" aria-label=\"Release assets\"><thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead> <tbody>")
346350 if templ_7745c5c3_Err != nil {
347351 return templ_7745c5c3_Err
348352 }
349353 for _, a := range p.Assets {
350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<tr><td><a href=\"")
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<tr><td><a href=\"")
351355 if templ_7745c5c3_Err != nil {
352356 return templ_7745c5c3_Err
353357 }
354358 var templ_7745c5c3_Var17 templ.SafeURL
355359 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href))
356360 if templ_7745c5c3_Err != nil {
357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 45}
361+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 45}
358362 }
359363 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
360364 if templ_7745c5c3_Err != nil {
361365 return templ_7745c5c3_Err
362366 }
363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" download>")
367+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" download>")
364368 if templ_7745c5c3_Err != nil {
365369 return templ_7745c5c3_Err
366370 }
367371 var templ_7745c5c3_Var18 string
368372 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)
369373 if templ_7745c5c3_Err != nil {
370- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 65}
374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 65}
371375 }
372376 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
373377 if templ_7745c5c3_Err != nil {
374378 return templ_7745c5c3_Err
375379 }
376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td>")
380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</a></td><td>")
377381 if templ_7745c5c3_Err != nil {
378382 return templ_7745c5c3_Err
379383 }
380384 var templ_7745c5c3_Var19 string
381385 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(a.Size)
382386 if templ_7745c5c3_Err != nil {
383- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 88}
387+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 88}
384388 }
385389 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
386390 if templ_7745c5c3_Err != nil {
387391 return templ_7745c5c3_Err
388392 }
389- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td><td>")
393+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td><td>")
390394 if templ_7745c5c3_Err != nil {
391395 return templ_7745c5c3_Err
392396 }
393397 var templ_7745c5c3_Var20 string
394398 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(a.ContentType)
395399 if templ_7745c5c3_Err != nil {
396- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 114}
400+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 114}
397401 }
398402 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
399403 if templ_7745c5c3_Err != nil {
400404 return templ_7745c5c3_Err
401405 }
402- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td></tr>")
406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</td></tr>")
403407 if templ_7745c5c3_Err != nil {
404408 return templ_7745c5c3_Err
405409 }
406410 }
407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</tbody></table>")
411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</tbody></table>")
408412 if templ_7745c5c3_Err != nil {
409413 return templ_7745c5c3_Err
410414 }
411415 }
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " <p><a href=\"")
416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " <p><a href=\"")
413417 if templ_7745c5c3_Err != nil {
414418 return templ_7745c5c3_Err
415419 }
416420 var templ_7745c5c3_Var21 templ.SafeURL
417421 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
418422 if templ_7745c5c3_Err != nil {
419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 65, Col: 42}
423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 72, Col: 42}
420424 }
421425 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
422426 if templ_7745c5c3_Err != nil {
423427 return templ_7745c5c3_Err
424428 }
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">Source</a></p>")
429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">Source</a></p>")
426430 if templ_7745c5c3_Err != nil {
427431 return templ_7745c5c3_Err
428432 }
@@ -245,27 +245,27 @@ func ReleaseDetail(l Layout, p ReleaseDetailPage) templ.Component {
245 }()245 }()
246 }246 }
247 ctx = templ.InitializeContext(ctx)247 ctx = templ.InitializeContext(ctx)
248- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<h1>")248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section class=\"mr-card\"><header class=\"show mr-h\"><h1>")
249 if templ_7745c5c3_Err != nil {249 if templ_7745c5c3_Err != nil {
250 return templ_7745c5c3_Err250 return templ_7745c5c3_Err
251 }251 }
252 var templ_7745c5c3_Var12 string252 var templ_7745c5c3_Var12 string
253 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)253 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
254 if templ_7745c5c3_Err != nil {254 if templ_7745c5c3_Err != nil {
255- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 43, Col: 15}255+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 45, Col: 17}
256 }256 }
257 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))257 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
258 if templ_7745c5c3_Err != nil {258 if templ_7745c5c3_Err != nil {
259 return templ_7745c5c3_Err259 return templ_7745c5c3_Err
260 }260 }
261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><p><span class=\"pill\">")261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><dl class=\"kv show-kv\"><dt>tag</dt><dd><span class=\"pill\">")
262 if templ_7745c5c3_Err != nil {262 if templ_7745c5c3_Err != nil {
263 return templ_7745c5c3_Err263 return templ_7745c5c3_Err
264 }264 }
265 var templ_7745c5c3_Var13 string265 var templ_7745c5c3_Var13 string
266 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Tag)266 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Tag)
267 if templ_7745c5c3_Err != nil {267 if templ_7745c5c3_Err != nil {
268- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 45, Col: 29}268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 48, Col: 35}
269 }269 }
270 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))270 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
271 if templ_7745c5c3_Err != nil {271 if templ_7745c5c3_Err != nil {
@@ -279,52 +279,56 @@ func ReleaseDetail(l Layout, p ReleaseDetailPage) templ.Component {
279 if templ_7745c5c3_Err != nil {279 if templ_7745c5c3_Err != nil {
280 return templ_7745c5c3_Err280 return templ_7745c5c3_Err
281 }281 }
282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</dd>")
283+ if templ_7745c5c3_Err != nil {
284+ return templ_7745c5c3_Err
285+ }
282 if p.SHA != "" {286 if p.SHA != "" {
283- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<a href=\"")287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>commit</dt><dd><a href=\"")
284 if templ_7745c5c3_Err != nil {288 if templ_7745c5c3_Err != nil {
285 return templ_7745c5c3_Err289 return templ_7745c5c3_Err
286 }290 }
287 var templ_7745c5c3_Var14 templ.SafeURL291 var templ_7745c5c3_Var14 templ.SafeURL
288 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CommitHref))292 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CommitHref))
289 if templ_7745c5c3_Err != nil {293 if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 47, Col: 41}294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 51, Col: 47}
291 }295 }
292 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))296 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
293 if templ_7745c5c3_Err != nil {297 if templ_7745c5c3_Err != nil {
294 return templ_7745c5c3_Err298 return templ_7745c5c3_Err
295 }299 }
296- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><code>")300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><code>")
297 if templ_7745c5c3_Err != nil {301 if templ_7745c5c3_Err != nil {
298 return templ_7745c5c3_Err302 return templ_7745c5c3_Err
299 }303 }
300 var templ_7745c5c3_Var15 string304 var templ_7745c5c3_Var15 string
301 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.SHA)305 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.SHA)
302 if templ_7745c5c3_Err != nil {306 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 47, Col: 57}307+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 51, Col: 63}
304 }308 }
305 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))309 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
306 if templ_7745c5c3_Err != nil {310 if templ_7745c5c3_Err != nil {
307 return templ_7745c5c3_Err311 return templ_7745c5c3_Err
308 }312 }
309- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</code></a> ")313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</code></a></dd>")
310 if templ_7745c5c3_Err != nil {314 if templ_7745c5c3_Err != nil {
311 return templ_7745c5c3_Err315 return templ_7745c5c3_Err
312 }316 }
313 }317 }
314- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"dim\">")318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<dt>published</dt><dd>")
315 if templ_7745c5c3_Err != nil {319 if templ_7745c5c3_Err != nil {
316 return templ_7745c5c3_Err320 return templ_7745c5c3_Err
317 }321 }
318 var templ_7745c5c3_Var16 string322 var templ_7745c5c3_Var16 string
319 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(p.Published)323 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(p.Published)
320 if templ_7745c5c3_Err != nil {324 if templ_7745c5c3_Err != nil {
321- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 49, Col: 34}325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 54, Col: 22}
322 }326 }
323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))327 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
324 if templ_7745c5c3_Err != nil {328 if templ_7745c5c3_Err != nil {
325 return templ_7745c5c3_Err329 return templ_7745c5c3_Err
326 }330 }
327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span></p><div class=\"md\">")331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</dd></dl></header><article class=\"mr-desc prose\">")
328 if templ_7745c5c3_Err != nil {332 if templ_7745c5c3_Err != nil {
329 return templ_7745c5c3_Err333 return templ_7745c5c3_Err
330 }334 }
@@ -332,97 +336,97 @@ func ReleaseDetail(l Layout, p ReleaseDetailPage) templ.Component {
332 if templ_7745c5c3_Err != nil {336 if templ_7745c5c3_Err != nil {
333 return templ_7745c5c3_Err337 return templ_7745c5c3_Err
334 }338 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><h2>Assets</h2>")339+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</article></section><h2>Assets</h2>")
336 if templ_7745c5c3_Err != nil {340 if templ_7745c5c3_Err != nil {
337 return templ_7745c5c3_Err341 return templ_7745c5c3_Err
338 }342 }
339 if len(p.Assets) == 0 {343 if len(p.Assets) == 0 {
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p class=\"dim\">no assets.</p>")344+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"dim\">no assets.</p>")
341 if templ_7745c5c3_Err != nil {345 if templ_7745c5c3_Err != nil {
342 return templ_7745c5c3_Err346 return templ_7745c5c3_Err
343 }347 }
344 } else {348 } else {
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<table class=\"list\" aria-label=\"Release assets\"><thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead> <tbody>")349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<table class=\"list\" aria-label=\"Release assets\"><thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead> <tbody>")
346 if templ_7745c5c3_Err != nil {350 if templ_7745c5c3_Err != nil {
347 return templ_7745c5c3_Err351 return templ_7745c5c3_Err
348 }352 }
349 for _, a := range p.Assets {353 for _, a := range p.Assets {
350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<tr><td><a href=\"")354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<tr><td><a href=\"")
351 if templ_7745c5c3_Err != nil {355 if templ_7745c5c3_Err != nil {
352 return templ_7745c5c3_Err356 return templ_7745c5c3_Err
353 }357 }
354 var templ_7745c5c3_Var17 templ.SafeURL358 var templ_7745c5c3_Var17 templ.SafeURL
355 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href))359 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href))
356 if templ_7745c5c3_Err != nil {360 if templ_7745c5c3_Err != nil {
357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 45}361+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 45}
358 }362 }
359 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))363 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
360 if templ_7745c5c3_Err != nil {364 if templ_7745c5c3_Err != nil {
361 return templ_7745c5c3_Err365 return templ_7745c5c3_Err
362 }366 }
363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" download>")367+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" download>")
364 if templ_7745c5c3_Err != nil {368 if templ_7745c5c3_Err != nil {
365 return templ_7745c5c3_Err369 return templ_7745c5c3_Err
366 }370 }
367 var templ_7745c5c3_Var18 string371 var templ_7745c5c3_Var18 string
368 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)372 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(a.Name)
369 if templ_7745c5c3_Err != nil {373 if templ_7745c5c3_Err != nil {
370- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 65}374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 65}
371 }375 }
372 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))376 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
373 if templ_7745c5c3_Err != nil {377 if templ_7745c5c3_Err != nil {
374 return templ_7745c5c3_Err378 return templ_7745c5c3_Err
375 }379 }
376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td>")380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</a></td><td>")
377 if templ_7745c5c3_Err != nil {381 if templ_7745c5c3_Err != nil {
378 return templ_7745c5c3_Err382 return templ_7745c5c3_Err
379 }383 }
380 var templ_7745c5c3_Var19 string384 var templ_7745c5c3_Var19 string
381 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(a.Size)385 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(a.Size)
382 if templ_7745c5c3_Err != nil {386 if templ_7745c5c3_Err != nil {
383- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 88}387+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 88}
384 }388 }
385 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))389 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
386 if templ_7745c5c3_Err != nil {390 if templ_7745c5c3_Err != nil {
387 return templ_7745c5c3_Err391 return templ_7745c5c3_Err
388 }392 }
389- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td><td>")393+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td><td>")
390 if templ_7745c5c3_Err != nil {394 if templ_7745c5c3_Err != nil {
391 return templ_7745c5c3_Err395 return templ_7745c5c3_Err
392 }396 }
393 var templ_7745c5c3_Var20 string397 var templ_7745c5c3_Var20 string
394 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(a.ContentType)398 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(a.ContentType)
395 if templ_7745c5c3_Err != nil {399 if templ_7745c5c3_Err != nil {
396- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 60, Col: 114}400+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 67, Col: 114}
397 }401 }
398 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))402 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
399 if templ_7745c5c3_Err != nil {403 if templ_7745c5c3_Err != nil {
400 return templ_7745c5c3_Err404 return templ_7745c5c3_Err
401 }405 }
402- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td></tr>")406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</td></tr>")
403 if templ_7745c5c3_Err != nil {407 if templ_7745c5c3_Err != nil {
404 return templ_7745c5c3_Err408 return templ_7745c5c3_Err
405 }409 }
406 }410 }
407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</tbody></table>")411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</tbody></table>")
408 if templ_7745c5c3_Err != nil {412 if templ_7745c5c3_Err != nil {
409 return templ_7745c5c3_Err413 return templ_7745c5c3_Err
410 }414 }
411 }415 }
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " <p><a href=\"")416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " <p><a href=\"")
413 if templ_7745c5c3_Err != nil {417 if templ_7745c5c3_Err != nil {
414 return templ_7745c5c3_Err418 return templ_7745c5c3_Err
415 }419 }
416 var templ_7745c5c3_Var21 templ.SafeURL420 var templ_7745c5c3_Var21 templ.SafeURL
417 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))421 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
418 if templ_7745c5c3_Err != nil {422 if templ_7745c5c3_Err != nil {
419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 65, Col: 42}423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/releases.templ`, Line: 72, Col: 42}
420 }424 }
421 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))425 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
422 if templ_7745c5c3_Err != nil {426 if templ_7745c5c3_Err != nil {
423 return templ_7745c5c3_Err427 return templ_7745c5c3_Err
424 }428 }
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">Source</a></p>")429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">Source</a></p>")
426 if templ_7745c5c3_Err != nil {430 if templ_7745c5c3_Err != nil {
427 return templ_7745c5c3_Err431 return templ_7745c5c3_Err
428 }432 }
M internal/adapters/web/releases_test.go +10 -1
@@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture {
3838 t.Helper()
3939 repo := repoName(t, "proj")
4040 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41- f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
4242 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
4343 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
4444 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
@@ -137,6 +137,15 @@ func TestReleasePages(t *testing.T) {
137137 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138138 assert.Contains(t, body, text)
139139 }
140+ notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2)
141+ require.Len(t, notes, 2)
142+ notesBody := strings.SplitN(notes[1], "</article>", 2)[0]
143+ assert.Contains(t, notesBody, "<pre")
144+ assert.Contains(t, notesBody, "<code")
145+ assert.Contains(t, notesBody, "curl")
146+ assert.Contains(t, notesBody, "</code></pre>")
147+ assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`)
148+ assert.Contains(t, body, `<section class="mr-card">`)
140149 assert.NotContains(t, body, "<script>alert")
141150 assert.NotContains(t, body, `href="javascript:`)
142151 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
@@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture {
38 t.Helper()38 t.Helper()
39 repo := repoName(t, "proj")39 repo := repoName(t, "proj")
40 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}40 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41- f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
42 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}42 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
43 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}43 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
44 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {44 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
@@ -137,6 +137,15 @@ func TestReleasePages(t *testing.T) {
137 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {137 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138 assert.Contains(t, body, text)138 assert.Contains(t, body, text)
139 }139 }
140+ notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2)
141+ require.Len(t, notes, 2)
142+ notesBody := strings.SplitN(notes[1], "</article>", 2)[0]
143+ assert.Contains(t, notesBody, "<pre")
144+ assert.Contains(t, notesBody, "<code")
145+ assert.Contains(t, notesBody, "curl")
146+ assert.Contains(t, notesBody, "</code></pre>")
147+ assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`)
148+ assert.Contains(t, body, `<section class="mr-card">`)
140 assert.NotContains(t, body, "<script>alert")149 assert.NotContains(t, body, "<script>alert")
141 assert.NotContains(t, body, `href="javascript:`)150 assert.NotContains(t, body, `href="javascript:`)
142 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)151 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
M internal/adapters/web/summary.go +21 -0
@@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
6868
6969 p.Facts = s.facts(d, now)
7070 p.Latest = s.latest(ctx, d)
71+ p.Release = s.summaryRelease(ctx, d.repo.Name, now)
7172 p.Actions = s.recentActions(ctx, d.repo.Name, now)
7273
7374 if rd, err := s.readme(ctx, d, ref); err != nil {
@@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no
272273 }
273274 return a, nil
274275 }
276+
277+// summaryRelease uses the same Latest selection as the releases page. A
278+// missing service or an unavailable release must not prevent repository browsing.
279+func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease {
280+ if s.cfg.Releases == nil {
281+ return nil
282+ }
283+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
284+ if err != nil {
285+ if !errors.Is(err, domain.ErrNotFound) {
286+ log := zerowrap.FromCtx(ctx)
287+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release")
288+ }
289+ return nil
290+ }
291+ if rel.Draft || rel.PublishedAt.IsZero() {
292+ return nil
293+ }
294+ return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())}
295+}
@@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
68 68
69 p.Facts = s.facts(d, now)69 p.Facts = s.facts(d, now)
70 p.Latest = s.latest(ctx, d)70 p.Latest = s.latest(ctx, d)
71+ p.Release = s.summaryRelease(ctx, d.repo.Name, now)
71 p.Actions = s.recentActions(ctx, d.repo.Name, now)72 p.Actions = s.recentActions(ctx, d.repo.Name, now)
72 73
73 if rd, err := s.readme(ctx, d, ref); err != nil {74 if rd, err := s.readme(ctx, d, ref); err != nil {
@@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no
272 }273 }
273 return a, nil274 return a, nil
274 }275 }
276+
277+// summaryRelease uses the same Latest selection as the releases page. A
278+// missing service or an unavailable release must not prevent repository browsing.
279+func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease {
280+ if s.cfg.Releases == nil {
281+ return nil
282+ }
283+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
284+ if err != nil {
285+ if !errors.Is(err, domain.ErrNotFound) {
286+ log := zerowrap.FromCtx(ctx)
287+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release")
288+ }
289+ return nil
290+ }
291+ if rel.Draft || rel.PublishedAt.IsZero() {
292+ return nil
293+ }
294+ return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())}
295+}
A internal/adapters/web/summary_releases_test.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestSummaryRelease(t *testing.T) {
20+ repo := repoName(t, "proj")
21+ for _, tc := range []struct {
22+ name string
23+ release domain.Release
24+ err error
25+ visible bool
26+ }{
27+ {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true},
28+ {name: "none", err: domain.ErrNotFound},
29+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ svc := mocks.NewReleaseService(t)
33+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
34+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
35+ rel := srv.summaryRelease(t.Context(), repo, fixedNow)
36+ out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ")
37+ if !tc.visible {
38+ assert.Nil(t, rel)
39+ assert.NotContains(t, out, "<dt>release</dt>")
40+ return
41+ }
42+ require.NotNil(t, rel)
43+ assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`)
44+ assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`)
45+ assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`)
46+ assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out)
47+ })
48+ }
49+}
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestSummaryRelease(t *testing.T) {
20+ repo := repoName(t, "proj")
21+ for _, tc := range []struct {
22+ name string
23+ release domain.Release
24+ err error
25+ visible bool
26+ }{
27+ {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true},
28+ {name: "none", err: domain.ErrNotFound},
29+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ svc := mocks.NewReleaseService(t)
33+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
34+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
35+ rel := srv.summaryRelease(t.Context(), repo, fixedNow)
36+ out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ")
37+ if !tc.visible {
38+ assert.Nil(t, rel)
39+ assert.NotContains(t, out, "<dt>release</dt>")
40+ return
41+ }
42+ require.NotNil(t, rel)
43+ assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`)
44+ assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`)
45+ assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`)
46+ assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out)
47+ })
48+ }
49+}
M internal/adapters/web/view.go +7 -1
@@ -211,12 +211,18 @@ type SummaryPage struct {
211211 Outline []Heading
212212 AllHref string
213213 // Latest is the newest tag, nil when the repository has none.
214- Latest *LatestTag
214+ Latest *LatestTag
215+ Release *LatestRelease
215216 // Actions is the recent runs of the rail, nil when the viewer cannot read
216217 // CI (or the server runs none).
217218 Actions *RecentActions
218219 }
219220
221+// LatestRelease is the published release selected as Latest by the release service.
222+type LatestRelease struct {
223+ Tag, Href, When, WhenAt, AllHref string
224+}
225+
220226 // RecentActions is the "Recent actions" block of the summary rail: the newest
221227 // runs. With no run the block is in the page but hidden, so that the first
222228 // run reaches it live.
@@ -211,12 +211,18 @@ type SummaryPage struct {
211 Outline []Heading211 Outline []Heading
212 AllHref string212 AllHref string
213 // Latest is the newest tag, nil when the repository has none.213 // Latest is the newest tag, nil when the repository has none.
214- Latest *LatestTag214+ Latest *LatestTag
215+ Release *LatestRelease
215 // Actions is the recent runs of the rail, nil when the viewer cannot read216 // Actions is the recent runs of the rail, nil when the viewer cannot read
216 // CI (or the server runs none).217 // CI (or the server runs none).
217 Actions *RecentActions218 Actions *RecentActions
218 }219 }
219 220
221+// LatestRelease is the published release selected as Latest by the release service.
222+type LatestRelease struct {
223+ Tag, Href, When, WhenAt, AllHref string
224+}
225+
220 // RecentActions is the "Recent actions" block of the summary rail: the newest226 // RecentActions is the "Recent actions" block of the summary rail: the newest
221 // runs. With no run the block is in the page but hidden, so that the first227 // runs. With no run the block is in the page but hidden, so that the first
222 // run reaches it live.228 // run reaches it live.

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 42

↑↓ moveenter openesc close