// uv.go is RFC 0020's pages: a repository's unversioned files, listed
// with fossil's columns and published or removed by a holder of y, the
// download route that redirects to the bucket, and the releases page
// that reads releases/<name>/ as one release per folder.
//
// No page here renders a file's content. A download is a redirect to
// the bucket, and the releases page reads a manifest's fields through
// the op, never its text.
package web
import (
"context"
"fmt"
"html/template"
"net/http"
"net/url"
"sort"
"strings"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/uv"
)
// uvView is the listing page.
type uvView struct {
Bound bool
// CanWrite is y on a repository with a bucket; CanBind is a, for
// the pointer to the bucket settings.
CanWrite bool
CanBind bool
Limit string
Sort string
Error string
Done string
Rows []uvRow
Removed []uvRow
}
type uvRow struct {
Name string
URL string
Size string
Hash string
Mtime int64
// When is the mtime as the page's clock reads it.
When string
Who string
}
// uvURL addresses one file under the repository's base URL: each
// segment escaped, the slashes kept, so the link reads as the name.
func uvURL(base, name string) string {
segs := strings.Split(name, "/")
for i, s := range segs {
segs[i] = url.PathEscape(s)
}
return base + "/uv/" + strings.Join(segs, "/")
}
func uvRowOf(base string, f uv.File) uvRow {
return uvRow{Name: f.Name, URL: uvURL(base, f.Name), Size: byteSize(int(f.Size)), Hash: f.Hash,
Mtime: f.Mtime, When: time.Unix(f.Mtime, 0).UTC().Format(time.RFC3339), Who: f.Who}
}
// sortUV orders rows by name, or newest first for "age", as fossil's
// /uvlist does with byage=1.
func sortUV(rows []uvRow, by string) {
sort.SliceStable(rows, func(i, j int) bool {
if by == "age" && rows[i].Mtime != rows[j].Mtime {
return rows[i].Mtime > rows[j].Mtime
}
return rows[i].Name < rows[j].Name
})
}
// releasesView is the releases page, release.list as drawn.
type releasesView struct {
Bound bool
CanBind bool
Groups []releaseGroup
}
type releaseGroup struct {
Name string
Newest bool
// Link is the release's own page; Single says the group is drawn
// as that page rather than as an entry of the list.
Link string
Single bool
CheckinURL string
When string
Files []uvRow
Sums string
Manifest *releaseManifestView
ManifestErr string
Notes *releaseNotesView
}
type releaseManifestView struct {
Version string
Date string
Checkin string
Tag string
Artifacts []releaseArtifactView
Source *releaseArtifactView
Omitted []releaseOmittedView
Qualification []releaseQualificationView
}
type releaseArtifactView struct {
Name string
Target string
Size string
SHA256 string
Qualification string
URL string
// SumsSHA256 is SHA256SUMS's digest for the file, set only when it
// differs from the manifest's.
SumsSHA256 string
}
type releaseOmittedView struct {
Target string `json:"target"`
Reason string `json:"reason"`
}
type releaseQualificationView struct {
Target string
Notes []string
}
type releaseNotesView struct {
Heading string
HTML template.HTML
}
// releaseListWire mirrors the release.list op.
type releaseListWire struct {
Bound bool `json:"bound"`
Releases []struct {
Name string `json:"name"`
Newest bool `json:"newest"`
Mtime int64 `json:"mtime"`
Files []uv.File `json:"files"`
Sums string `json:"sums"`
Manifest *struct {
Version string `json:"version"`
Date string `json:"date"`
Checkin string `json:"checkin"`
Tag string `json:"tag"`
Artifacts []releaseArtifactWire `json:"artifacts"`
Source *releaseArtifactWire `json:"source"`
Omitted []releaseOmittedView `json:"omitted"`
Qualification []releaseQualificationView `json:"qualification"`
} `json:"manifest"`
ManifestErr string `json:"manifestErr"`
Notes *struct {
Heading string `json:"heading"`
HTML string `json:"html"`
} `json:"notes"`
} `json:"releases"`
}
type releaseArtifactWire struct {
Name string `json:"name"`
Target string `json:"target"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Qualification string `json:"qualification"`
File string `json:"file"`
SumsSHA256 string `json:"sumsSha256"`
}
func (a releaseArtifactWire) view(base string) releaseArtifactView {
v := releaseArtifactView{Name: a.Name, Target: a.Target, SHA256: a.SHA256, Qualification: a.Qualification, SumsSHA256: a.SumsSHA256}
if a.Size > 0 {
v.Size = byteSize(int(a.Size))
}
if a.File != "" {
v.URL = uvURL(base, a.File)
}
return v
}
// releaseSummary mirrors the release.summary op.
type releaseSummary struct {
Count int `json:"count"`
Latest string `json:"latest"`
Mtime int64 `json:"mtime"`
}
// releasesBox is the Files page's Releases block, GitHub's sidebar
// entry: how many, and the newest, from the catalog alone.
type releasesBox struct {
Count int
Latest string
When string
Link string
All string
}
// releasesBoxFor is the block for a repository with releases, nil for
// one without or when the catalog cannot say.
func releasesBoxFor(r *ops.Registry, req *http.Request, repo string) *releasesBox {
out, err := invoke(req.Context(), r, "release.summary", url.Values{"repo": {repo}})
if err != nil {
return nil
}
var sum releaseSummary
remarshal(out, &sum)
if sum.Count == 0 {
return nil
}
base := hooksOf(req.Context()).repoURL(repo)
return &releasesBox{Count: sum.Count, Latest: sum.Latest, Link: releaseURL(base, sum.Latest),
All: base + "/releases", When: time.Unix(sum.Mtime, 0).UTC().Format(time.RFC3339)}
}
// releaseURL is one release's page.
func releaseURL(base, name string) string { return base + "/releases/" + url.PathEscape(name) }
func releasesViewOf(base string, w releaseListWire) *releasesView {
v := &releasesView{Bound: w.Bound}
for _, r := range w.Releases {
g := releaseGroup{Name: r.Name, Newest: r.Newest, ManifestErr: r.ManifestErr,
Link: releaseURL(base, r.Name), When: time.Unix(r.Mtime, 0).UTC().Format(time.RFC3339)}
for _, f := range r.Files {
row := uvRowOf(base, f)
row.Name = strings.TrimPrefix(f.Name, "releases/"+r.Name+"/")
g.Files = append(g.Files, row)
}
if r.Sums != "" {
g.Sums = uvURL(base, r.Sums)
}
if m := r.Manifest; m != nil {
mv := &releaseManifestView{Version: m.Version, Date: m.Date, Checkin: m.Checkin, Tag: m.Tag,
Omitted: m.Omitted, Qualification: m.Qualification}
for _, a := range m.Artifacts {
mv.Artifacts = append(mv.Artifacts, a.view(base))
}
if m.Source != nil {
s := m.Source.view(base)
mv.Source = &s
}
g.Manifest = mv
if m.Checkin != "" {
// The op keeps only a hash here, so this names a check-in.
g.CheckinURL = base + "/checkin/" + url.PathEscape(m.Checkin)
}
}
if r.Notes != nil {
// Rendered and sanitized by the op, the way changelog.get
// renders the forge's own.
g.Notes = &releaseNotesView{Heading: r.Notes.Heading, HTML: template.HTML(r.Notes.HTML)}
}
v.Groups = append(v.Groups, g)
}
return v
}
// UVUploadFunc is the page's upload form carried into uv.put, which
// the composition root builds from the api layer's UVUploadForm. It
// answers the name published.
type UVUploadFunc func(ctx context.Context, repo string, w http.ResponseWriter, req *http.Request) (string, error)
// WithUVUpload attaches the unversioned upload form's path.
func WithUVUpload(fn UVUploadFunc) func(*handlerOpts) {
return func(o *handlerOpts) { o.uvUpload = fn }
}
// uvFlash carries what a write did back to the listing in a flash
// cookie the server set, never in the URL, where anyone could word a
// link that shows their own sentence as this forge's. A refusal is
// marked so the page draws it as one.
func uvFlash(w http.ResponseWriter, req *http.Request, words string, refused bool) {
if refused {
words = "!" + words
}
setFlash(w, req, url.QueryEscape(words))
}
func uvTakeFlash(w http.ResponseWriter, req *http.Request) (done, refused string) {
words, err := url.QueryUnescape(takeFlash(w, req))
if err != nil || words == "" {
return "", ""
}
if r, ok := strings.CutPrefix(words, "!"); ok {
return "", r
}
return words, ""
}
func uvRoutes(mux *http.ServeMux, r *ops.Registry, caps Caps, verify Verify, sessions Sessions, upload UVUploadFunc) {
list := func(req *http.Request, repo string) (files []uv.File, bound bool, limit int64, err error) {
out, err := invoke(req.Context(), r, "uv.list", url.Values{"repo": {repo}})
if err != nil {
return nil, false, 0, err
}
var l struct {
Files []uv.File `json:"files"`
Bound bool `json:"bound"`
Limit int64 `json:"limit"`
}
remarshal(out, &l)
return l.Files, l.Bound, l.Limit, nil
}
mux.HandleFunc("GET /r/{repo}/uv", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
base := hooksOf(req.Context()).repoURL(repo)
files, bound, limit, err := list(req, repo)
if err != nil {
fail(r, w, req, err)
return
}
q := req.URL.Query()
v := &uvView{Bound: bound, Sort: "name", CanBind: mayCap(caps, verify, sessions, repo, "a", ops.Admin, req)}
v.Done, v.Error = uvTakeFlash(w, req)
if q.Get("sort") == "age" {
v.Sort = "age"
}
if bound {
v.CanWrite = upload != nil && mayCap(caps, verify, sessions, repo, "y", ops.Token, req)
v.Limit = byteSize(int(min(limit, api.UVFormLimit)))
}
for _, f := range files {
if f.Deleted {
v.Removed = append(v.Removed, uvRowOf(base, f))
} else {
v.Rows = append(v.Rows, uvRowOf(base, f))
}
}
sortUV(v.Rows, v.Sort)
sortUV(v.Removed, "age")
render(r, w, req, uvListPage, pageData{Title: "Unversioned files", OwnH1: true, Repo: repo,
UV: v, SettingsJS: v.CanWrite})
})
// The form: streamed by the api layer into uv.put, whose gates are
// the ones that count; the letter here only spares a refusal.
mux.HandleFunc("POST /r/{repo}/uv", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateCap(caps, verify, sessions, repo, "y", ops.Token, w, req)
if !ok {
return
}
if upload == nil {
fail(r, w, req, fmt.Errorf("this forge takes no uploads: %w", ops.ErrUnavailable))
return
}
name, err := upload(req.Context(), repo, w, req)
if err != nil {
uvFlash(w, req, refusalWords(err), true)
} else {
uvFlash(w, req, "published "+name, false)
}
http.Redirect(w, req, hooksOf(req.Context()).repoURL(repo)+"/uv", http.StatusSeeOther)
})
// Remove names the version the row showed: uv.rm refuses if the file
// has been replaced since, and dates the tombstone after that
// version, so a file published this second can be removed now.
mux.HandleFunc("POST /r/{repo}/uv/rm", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateCap(caps, verify, sessions, repo, "y", ops.Token, w, req)
if !ok {
return
}
if err := req.ParseForm(); err != nil {
fail(r, w, req, err)
return
}
name, seen := req.PostForm.Get("name"), req.PostForm.Get("seen")
if _, err := invoke(req.Context(), r, "uv.rm", url.Values{"repo": {repo}, "name": {name},
"ifMtime": {seen}}); err != nil {
uvFlash(w, req, refusalWords(err), true)
} else {
uvFlash(w, req, "removed "+name, false)
}
http.Redirect(w, req, hooksOf(req.Context()).repoURL(repo)+"/uv", http.StatusSeeOther)
})
// The download (RFC 0020, the web): a redirect to the bucket, never
// the bytes. A missing file is 404 whether or not the repository has
// a bucket: a redirect to the listing would hand a script following
// redirects (deploy.sh --release, curl -fL) a page in place of the
// file it asked for.
mux.HandleFunc("GET /r/{repo}/uv/{name...}", func(w http.ResponseWriter, req *http.Request) {
repo, name := req.PathValue("repo"), req.PathValue("name")
out, err := invoke(req.Context(), r, "uv.get", url.Values{"repo": {repo}, "name": {name}})
if err != nil {
fail(r, w, req, err)
return
}
var got struct {
URL string `json:"url"`
}
remarshal(out, &got)
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, req, got.URL, http.StatusFound)
})
// GitHub's spelling: /releases/latest is a redirect to the newest
// release's own page, so the address a reader keeps always moves.
// The newest comes from the catalog alone. A folder named latest
// is reached from the list; this route answers first.
mux.HandleFunc("GET /r/{repo}/releases/latest", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
base := hooksOf(req.Context()).repoURL(repo)
out, err := invoke(req.Context(), r, "release.summary", url.Values{"repo": {repo}})
if err != nil {
fail(r, w, req, err)
return
}
var sum releaseSummary
remarshal(out, &sum)
w.Header().Set("Cache-Control", "no-store")
if sum.Latest == "" {
http.Redirect(w, req, base+"/releases", http.StatusFound)
return
}
http.Redirect(w, req, releaseURL(base, sum.Latest), http.StatusFound)
})
mux.HandleFunc("GET /r/{repo}/releases/{name}", func(w http.ResponseWriter, req *http.Request) {
repo, name := req.PathValue("repo"), req.PathValue("name")
out, err := invoke(req.Context(), r, "release.list", url.Values{"repo": {repo}, "name": {name}})
if err != nil {
fail(r, w, req, err)
return
}
var wire releaseListWire
remarshal(out, &wire)
v := releasesViewOf(hooksOf(req.Context()).repoURL(repo), wire)
if len(v.Groups) != 1 {
fail(r, w, req, fmt.Errorf("no release %q: %w", name, ops.ErrNotFound))
return
}
g := v.Groups[0]
g.Single = true
render(r, w, req, releasePage, pageData{Title: g.Name, OwnH1: true, Repo: repo, Release: &g})
})
mux.HandleFunc("GET /r/{repo}/releases", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "release.list", url.Values{"repo": {repo}})
if err != nil {
fail(r, w, req, err)
return
}
var wire releaseListWire
remarshal(out, &wire)
v := releasesViewOf(hooksOf(req.Context()).repoURL(repo), wire)
v.CanBind = mayCap(caps, verify, sessions, repo, "a", ops.Admin, req)
render(r, w, req, releasesPage, pageData{Title: "Releases", OwnH1: true, Repo: repo, Releases: v})
})
}
// uvUnbound is the no-bucket sentence both pages share.
const uvUnbound = `{{define "uv-unbound"}}<div class="uv-setup" data-hook="uv-unbound">
<h2>Set up a bucket for UV</h2>
<p>This repository has no bucket of its own, so it has no unversioned files yet. They live in an
S3-compatible bucket the repository binds (Cloudflare R2, Backblaze B2, Amazon S3, MinIO and the
like), the same binding its attachments use; the forge keeps only the list.</p>
<ol class="uv-steps">
<li><strong>Bind a bucket.</strong> {{if .CanBind}}In <a data-hook="uv-bind" href="{{repoURL .Repo}}/settings#attachments">Settings, under Attachments</a>,
give the endpoint, the bucket, its region and an access key that may read and write it, then probe it.
{{- else}} A repository admin does this in the repository's settings, under Attachments: an endpoint,
a bucket, its region and an access key that may read and write it.{{end}}</li>
<li><strong>Publish.</strong> Anyone holding the Write-UV letter (<code>y</code>) uploads from this page once
the bucket is bound, or from a fossil checkout: <code>fossil uv add FILE</code>, then
<code>fossil sync -u</code> against this forge.</li>
<li><strong>Releases.</strong> Files under <code>releases/<version>/</code> show as a release, with
<code>release-manifest.json</code> and <code>SHA256SUMS</code> read when present.</li>
</ol>
</div>{{end}}`
var uvListPage = page(template.Must(template.Must(base.Clone()).Parse(uvUnbound + `{{define "content"}}{{$r := .Repo}}{{with .UV}}
<div class="uv" data-hook="uv">
<div class="tn-head"><h1>Unversioned files</h1>{{if $.Counts.HasReleases}}<a class="tn-new" href="{{repoURL $r}}/releases">releases →</a>{{end}}</div>
<p class="tn-lede">Files published beside the repository rather than in it: release downloads,
generated documents. A name holds its newest version only, and a replaced file is gone. These
are fossil's unversioned files; <code>fossil uv</code> syncs them with this forge.</p>
{{if not .Bound}}{{template "uv-unbound" (dict "Repo" $r "CanBind" .CanBind)}}
{{else}}
{{with .Error}}<p class="uv-msg uv-err" data-hook="uv-error" role="alert">{{.}}</p>{{end}}
{{with .Done}}<p class="uv-msg" data-hook="uv-done" role="status">{{.}}</p>{{end}}
{{- /* Only where a write would land: a tracking mirror takes none,
unversioned ones included, so the form would collect a refusal. */ -}}
{{if and .CanWrite (not $.Posture.Mirror.Tracking)}}
<form class="uv-up" data-hook="uv-upload" method="post" enctype="multipart/form-data" action="{{repoURL $r}}/uv">
<label class="uv-f uv-file"><span>File</span><input type="file" name="file" required></label>
<label class="uv-f"><span>Folder</span><input name="prefix" placeholder="releases/1.0/" maxlength="400" autocomplete="off" spellcheck="false"></label>
<label class="uv-f"><span>Name</span><input name="name" placeholder="the file's own" maxlength="500" autocomplete="off" spellcheck="false"></label>
<button class="btn btn-primary" type="submit">Publish</button>
<p class="uv-up-note">Up to {{.Limit}}. Publishing a name that exists replaces it, with no history to go back to.</p>
</form>{{end}}
{{if .Rows}}
<div class="uv-bar"><span class="uv-n">{{len .Rows}} {{plural (len .Rows) "file" "files"}}</span>
<span class="uv-sort">sort by <a data-hook="uv-sort" data-sort="name" href="{{repoURL $r}}/uv?sort=name"{{if eq .Sort "name"}} aria-current="true"{{end}}>name</a>
<a data-hook="uv-sort" data-sort="age" href="{{repoURL $r}}/uv?sort=age"{{if eq .Sort "age"}} aria-current="true"{{end}}>newest</a></span></div>
<div class="dt-wrap"><table class="dt dt-stack uv-t" data-hook="uv-list"><thead><tr><th>Name</th><th>Size</th><th>Hash</th><th>Modified</th><th>By</th>{{if .CanWrite}}<th class="dt-act"><span class="sr-only">remove</span></th>{{end}}</tr></thead><tbody>
{{range .Rows}}<tr data-hook="uv-row" data-name="{{.Name}}" data-mtime="{{.Mtime}}">
<td class="uv-name"><svg class="ic" aria-hidden="true"><use href="#i-file"/></svg><a href="{{.URL}}">{{.Name}}</a></td>
<td class="uv-size" data-label="size">{{.Size}}</td>
<td data-label="hash"><code class="uv-hash" data-hook="uv-hash" title="{{.Hash}}">{{printf "%.10s" .Hash}}</code></td>
<td data-label="modified">{{ago .When}}</td>
<td data-label="by" data-hook="uv-who">{{who $r .Who}}</td>
{{if $.UV.CanWrite}}<td class="dt-act"><form method="post" action="{{repoURL $r}}/uv/rm"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="seen" value="{{.Mtime}}"><button class="btn btn-danger" type="submit" data-hook="uv-remove" data-confirm="{{.Name}} is removed for everyone, and clones drop it on their next sync. The bytes are gone from the bucket.">Remove</button></form></td>{{end}}
</tr>
{{end}}</tbody></table></div>
{{else}}<p class="tn-empty" data-hook="uv-empty">Nothing published yet.{{if .CanWrite}} Use the form above, or <code>fossil uv add</code> then <code>fossil sync -u</code> against this forge.{{end}}</p>{{end}}
{{if .Removed}}<details class="uv-removed" data-hook="uv-removed">
<summary>{{len .Removed}} removed</summary>
<div class="dt-wrap"><table class="dt dt-stack uv-t"><thead><tr><th>Name</th><th>Removed</th><th>By</th></tr></thead><tbody>
{{range .Removed}}<tr class="muted" data-hook="uv-removed-row" data-name="{{.Name}}"><td class="uv-name"><del>{{.Name}}</del></td><td data-label="removed">{{ago .When}}</td><td data-label="by">{{who $r .Who}}</td></tr>
{{end}}</tbody></table></div>
</details>{{end}}
{{end}}
{{if .CanWrite}}<dialog id="confirm" class="confirm">
<form method="dialog">
<p class="confirm-why"></p>
<p class="confirm-acts"><button type="button" class="confirm-no">Cancel</button> <button type="button" class="confirm-do danger-btn">Continue</button></p>
</form>
</dialog>{{end}}
</div>{{end}}{{end}}`)))
// releaseGroupSource is one release as both the list and the release's
// own page draw it, so the manifest is rendered in one place.
const releaseGroupSource = `{{define "release-group"}}{{$g := .}}
<section class="rel-g{{if .Newest}} rel-newest{{end}}" data-hook="release" data-name="{{.Name}}" id="release-{{.Name}}">
<header class="rel-h">{{if .Single}}<h1 class="rel-title">{{.Name}}</h1>{{else}}<h2><a data-hook="release-link" href="{{.Link}}">{{.Name}}</a></h2>{{end}}
{{if .Newest}}<span class="latest-pill" data-hook="release-newest">Latest</span>{{end}}
<span class="rel-meta">{{with .Manifest}}{{if .Date}}{{.Date}}{{end}}{{if $g.CheckinURL}} · <a data-hook="release-checkin" href="{{$g.CheckinURL}}" title="the check-in it was built from"><code>{{printf "%.10s" .Checkin}}</code></a>{{end}}{{if .Tag}} · <span class="rel-tag">{{.Tag}}</span>{{end}}{{else}}{{ago .When}}{{end}}</span></header>
<div class="rel-body{{if .Notes}} with-notes{{end}}">
<div class="rel-main">
{{with .Manifest}}
<div class="dt-wrap"><table class="dt dt-stack" data-hook="release-manifest"><thead><tr><th>Target</th><th>File</th><th>Size</th><th>SHA-256</th><th>Qualified</th></tr></thead><tbody>
{{range .Artifacts}}<tr data-hook="release-artifact" data-target="{{.Target}}"><td class="rel-target">{{.Target}}</td><td class="rel-file" data-label="file">{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td data-label="size">{{.Size}}</td><td data-label="sha-256"><code class="uv-hash" title="{{.SHA256}}">{{printf "%.12s" .SHA256}}</code>{{with .SumsSHA256}} <span class="uv-err" data-hook="release-sums-mismatch" title="SHA256SUMS says {{.}}">does not match SHA256SUMS</span>{{end}}</td><td{{if .Qualification}} data-label="qualified"{{end}}>{{.Qualification}}</td></tr>
{{end}}{{with .Source}}<tr data-hook="release-artifact" data-target="source"><td class="rel-target">source</td><td class="rel-file" data-label="file">{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td data-label="size">{{.Size}}</td><td data-label="sha-256"><code class="uv-hash" title="{{.SHA256}}">{{printf "%.12s" .SHA256}}</code>{{with .SumsSHA256}} <span class="uv-err" data-hook="release-sums-mismatch" title="SHA256SUMS says {{.}}">does not match SHA256SUMS</span>{{end}}</td><td{{if .Qualification}} data-label="qualified"{{end}}>{{.Qualification}}</td></tr>{{end}}
</tbody></table></div>
{{if .Omitted}}<p class="rel-note" data-hook="release-omitted">Not built for {{range $i, $o := .Omitted}}{{if $i}} · {{end}}<span data-hook="release-omitted-target" data-name="{{$o.Target}}"><span class="rel-target">{{$o.Target}}</span>{{with $o.Reason}} <span class="rel-why" data-hook="release-omitted-reason">({{.}})</span>{{end}}</span>{{end}}.</p>{{end}}
{{if .Qualification}}<details class="rel-qual" data-hook="release-qualification"><summary>How each target was qualified</summary>
<dl>{{range .Qualification}}<dt>{{.Target}}</dt><dd>{{range $i, $n := .Notes}}{{if $i}} · {{end}}{{$n}}{{end}}</dd>{{end}}</dl></details>{{end}}
{{if $g.Sums}}<p class="rel-note">Checksums are also in <a data-hook="release-sums" href="{{$g.Sums}}">SHA256SUMS</a>.</p>{{end}}
{{else}}
<ul class="rel-files" data-hook="release-files">{{range .Files}}<li><a href="{{.URL}}">{{.Name}}</a> <span class="rel-size">{{.Size}}</span></li>{{end}}</ul>
{{with .ManifestErr}}<p class="rel-note uv-err" data-hook="release-manifest-error">{{.}}</p>{{end}}
{{if .Sums}}<p class="rel-note">No manifest; checksums come from <a data-hook="release-sums" href="{{.Sums}}">SHA256SUMS</a>.</p>
{{else}}<p class="rel-note">No manifest and no SHA256SUMS: nothing here vouches for these files.</p>{{end}}
{{end}}
</div>
{{with .Notes}}<aside class="rel-notes" data-hook="release-notes"><h3>{{.Heading}}</h3><div class="tkt-body readme">{{.HTML}}</div></aside>{{end}}
</div>
</section>
{{end}}`
var releasesPage = page(template.Must(template.Must(base.Clone()).Parse(uvUnbound + releaseGroupSource + `{{define "content"}}{{$r := .Repo}}{{with .Releases}}
<div class="rel" data-hook="releases">
<div class="tn-head"><h1>Releases</h1><a class="tn-new" href="{{repoURL $r}}/uv">all files →</a></div>
<p class="tn-lede">Each folder under <code>releases/</code> in the repository's unversioned files is one
release, newest first.</p>
{{if not .Bound}}{{template "uv-unbound" (dict "Repo" $r "CanBind" .CanBind)}}
{{else if not .Groups}}<p class="tn-empty" data-hook="releases-empty">Nothing is published under <code>releases/</code> yet.</p>
{{else}}{{range .Groups}}{{template "release-group" .}}
{{end}}{{end}}
</div>{{end}}{{end}}`)))
// releasePage is one release: the list's entry as a page, its notes in
// full beside it.
var releasePage = page(template.Must(template.Must(base.Clone()).Parse(releaseGroupSource + `{{define "content"}}{{$r := .Repo}}
<div class="rel rel-one" data-hook="release-page">
<p class="w-crumb"><a href="{{repoURL $r}}/releases">releases</a> / <span>{{.Release.Name}}</span></p>
{{template "release-group" .Release}}
</div>{{end}}`)))