packages.wenpai.net/internal/http/templates.go
elementary 741755ac9b
Some checks failed
ci / GolangCI-Lint (push) Failing after 7m10s
ci / Go Modules Tidy (push) Failing after 5m26s
ci / Go Test (push) Failing after 1h3m11s
ci / Integration Test (push) Failing after 5s
govulncheck / govulncheck (push) Failing after 1s
refactor: mirror-mode cleanups — upstream monthly installs, remove untagged, simplify status
2026-04-12 11:14:17 +08:00

535 lines
13 KiB
Go

package http
import (
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/roots/wp-packages/internal/telemetry"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed all:static
var staticFS embed.FS
// assetHashes maps static file paths (e.g. "assets/styles/app.css") to a
// short content hash computed once at startup from the embedded filesystem.
var assetHashes = func() map[string]string {
hashes := make(map[string]string)
_ = fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
data, err := staticFS.ReadFile(path)
if err != nil {
return nil
}
h := sha256.Sum256(data)
// strip "static/" prefix to match URL paths
key := strings.TrimPrefix(path, "static/")
hashes[key] = hex.EncodeToString(h[:])[:12]
return nil
})
return hashes
}()
// assetPath inserts a content hash into the filename for cache busting.
// e.g. "/assets/styles/app.css" → "/assets/styles/app.a1b2c3d4e5f6.css"
func assetPath(path string) string {
key := strings.TrimPrefix(path, "/")
v, ok := assetHashes[key]
if !ok {
return path
}
ext := filepath.Ext(path)
return path[:len(path)-len(ext)] + "." + v + ext
}
var funcMap = template.FuncMap{
"assetPath": assetPath,
"formatNumber": formatNumber,
"formatNumberComma": formatNumberComma,
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
"paginate": paginateURL,
"paginatePartial": paginatePartialURL,
"jsonLD": jsonLD,
"formatCST": formatCST,
"timeAgo": timeAgo,
"timeAgoShort": timeAgoShort,
"formatDuration": formatDuration,
"pageRange": pageRange,
"untaggedPaginate": untaggedPaginateURL,
"untaggedPaginateP": untaggedPaginatePartialURL,
"pct": func(n, total int64) string {
if total == 0 {
return "0"
}
return fmt.Sprintf("%.1f", float64(n)*100/float64(total))
},
"installChart": installChart,
"wporgURL": func(composerName string) string {
// "wp-plugin/slug" → "https://wordpress.org/plugins/slug/"
// "wp-theme/slug" → "https://wordpress.org/themes/slug/"
parts := strings.SplitN(composerName, "/", 2)
if len(parts) != 2 {
return "https://wordpress.org/"
}
section := "plugins"
if parts[0] == "wp-theme" {
section = "themes"
}
return "https://wordpress.org/" + section + "/" + parts[1] + "/"
},
}
type templateSet struct {
index *template.Template
indexPartial *template.Template
detail *template.Template
compare *template.Template
docs *template.Template
wordpressCore *template.Template
notFound *template.Template
adminLogs *template.Template
status *template.Template
}
func loadTemplates(env string) *templateSet {
funcMap["isProduction"] = func() bool { return env == "production" }
return &templateSet{
index: parse("templates/layout.html", "templates/index.html", "templates/package_results.html"),
indexPartial: parse("templates/package_results.html"),
detail: parse("templates/layout.html", "templates/detail.html"),
compare: parse("templates/layout.html", "templates/compare.html"),
docs: parse("templates/layout.html", "templates/docs.html"),
wordpressCore: parse("templates/layout.html", "templates/wordpress_core.html"),
notFound: parse("templates/layout.html", "templates/404.html"),
adminLogs: parse("templates/admin_layout.html", "templates/admin_logs.html"),
status: parse("templates/layout.html", "templates/status.html"),
}
}
func parse(files ...string) *template.Template {
return template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, files...))
}
func render(w http.ResponseWriter, r *http.Request, tmpl *template.Template, name string, data any) {
if m, ok := data.(map[string]any); ok {
m["Path"] = r.URL.Path
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
captureError(r, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func formatNumber(n int64) string {
if n >= 1_000_000 {
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
}
if n >= 1_000 {
return fmt.Sprintf("%.0fK", float64(n)/1_000)
}
return fmt.Sprintf("%d", n)
}
func formatNumberComma(n int64) string {
s := fmt.Sprintf("%d", n)
if n < 1000 {
return s
}
var result []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
return string(result)
}
// formatAxisLabel formats a number for chart Y-axis labels, keeping enough
// precision to avoid collisions (e.g. 1500 → "1.5K" instead of "2K").
func formatAxisLabel(n int) string {
if n >= 1_000_000 {
v := float64(n) / 1_000_000
if v == float64(int(v)) {
return fmt.Sprintf("%dM", int(v))
}
return fmt.Sprintf("%.1fM", v)
}
if n >= 1_000 {
v := float64(n) / 1_000
if v == float64(int(v)) {
return fmt.Sprintf("%dK", int(v))
}
return fmt.Sprintf("%.1fK", v)
}
return fmt.Sprintf("%d", n)
}
type publicFilters struct {
Search string
Type string
Sort string
}
func paginateURL(f publicFilters, page int) string {
v := url.Values{}
if f.Search != "" {
v.Set("search", f.Search)
}
if f.Type != "" {
v.Set("type", f.Type)
}
if f.Sort != "" && f.Sort != "composer_installs" {
v.Set("sort", f.Sort)
}
if page > 1 {
v.Set("page", fmt.Sprintf("%d", page))
}
q := v.Encode()
if q == "" {
return "/"
}
return "/?" + q
}
func paginatePartialURL(f publicFilters, page int) string {
v := url.Values{}
if f.Search != "" {
v.Set("search", f.Search)
}
if f.Type != "" {
v.Set("type", f.Type)
}
if f.Sort != "" && f.Sort != "composer_installs" {
v.Set("sort", f.Sort)
}
if page > 1 {
v.Set("page", fmt.Sprintf("%d", page))
}
q := v.Encode()
if q == "" {
return "/packages-partial"
}
return "/packages-partial?" + q
}
func untaggedPaginateURL(filter, search, author, sort string, page int) string {
v := url.Values{}
if filter != "" {
v.Set("filter", filter)
}
if search != "" {
v.Set("search", search)
}
if author != "" {
v.Set("author", author)
}
if sort != "" && sort != "active_installs" {
v.Set("sort", sort)
}
if page > 1 {
v.Set("page", fmt.Sprintf("%d", page))
}
q := v.Encode()
if q == "" {
return "/untagged"
}
return "/untagged?" + q
}
func untaggedPaginatePartialURL(filter, search, author, sort string, page int) string {
v := url.Values{}
if filter != "" {
v.Set("filter", filter)
}
if search != "" {
v.Set("search", search)
}
if author != "" {
v.Set("author", author)
}
if sort != "" && sort != "active_installs" {
v.Set("sort", sort)
}
if page > 1 {
v.Set("page", fmt.Sprintf("%d", page))
}
q := v.Encode()
if q == "" {
return "/untagged-partial"
}
return "/untagged-partial?" + q
}
func jsonLD(data any) template.HTML {
if data == nil {
return ""
}
// If it's a slice, emit one script tag per item
if items, ok := data.([]any); ok {
var out string
for _, item := range items {
b, err := json.Marshal(item)
if err != nil {
continue
}
out += `<script type="application/ld+json">` + string(b) + `</script>`
}
return template.HTML(out)
}
b, err := json.Marshal(data)
if err != nil {
return ""
}
return template.HTML(`<script type="application/ld+json">` + string(b) + `</script>`)
}
var cst = func() *time.Location {
loc, err := time.LoadLocation("America/Chicago")
if err != nil {
return time.FixedZone("CST", -6*60*60)
}
return loc
}()
// formatCST converts an RFC3339 or "2006-01-02 15:04:05" string to "Jan 2, 3:04 PM" in America/Chicago.
func formatCST(raw string) string {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", raw)
}
if err != nil {
return raw
}
return t.In(cst).Format("Jan 2, 3:04 PM")
}
// formatDuration converts seconds (as *int) to a human-readable duration like "2m 35s".
func formatDuration(v *int) string {
if v == nil {
return ""
}
s := *v
if s < 60 {
return fmt.Sprintf("%ds", s)
}
return fmt.Sprintf("%dm %ds", s/60, s%60)
}
// timeAgo returns a human-readable relative time like "23 minutes ago".
func timeAgo(raw string) string {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return raw
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
default:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}
// timeAgoShort is like timeAgo but shows "Jan 2006" for dates older than 30 days.
func timeAgoShort(raw string) string {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return raw
}
if time.Since(t).Hours()/24 > 30 {
return t.Format("Jan 2006")
}
return timeAgo(raw)
}
// pageRange returns page numbers to display in pagination. 0 represents an ellipsis.
// Shows current page with one neighbor on each side, plus first and last pages.
func pageRange(current, total int) []int {
if total <= 5 {
pages := make([]int, total)
for i := range pages {
pages[i] = i + 1
}
return pages
}
seen := map[int]bool{}
var pages []int
for _, p := range []int{1, current - 1, current, current + 1, total} {
if p >= 1 && p <= total && !seen[p] {
seen[p] = true
pages = append(pages, p)
}
}
// Insert ellipses where there are gaps
var result []int
for i, p := range pages {
if i > 0 && p > pages[i-1]+1 {
result = append(result, 0)
}
result = append(result, p)
}
return result
}
// installChart renders a server-side SVG bar chart for monthly install data.
func installChart(data []telemetry.MonthlyInstall) template.HTML {
if len(data) == 0 {
return ""
}
// Use last 12 months max
if len(data) > 12 {
data = data[len(data)-12:]
}
// Find max value for scaling
max := 0
for _, m := range data {
if m.Installs > max {
max = m.Installs
}
}
if max == 0 {
return ""
}
// Compute nice Y-axis tick values and scale to the highest tick
ticks := yAxisTicks(max)
if len(ticks) > 0 {
max = ticks[len(ticks)-1]
}
n := len(data)
padLeft := 44.0
padRight := 4.0
padTop := 20.0
padBottom := 28.0
chartW := 600.0
chartH := 160.0
totalW := padLeft + chartW + padRight
barGap := 6.0
maxBarW := 48.0
barW := (chartW - float64(n-1)*barGap) / float64(n)
if barW > maxBarW {
barW = maxBarW
}
totalH := padTop + chartH + padBottom
var b strings.Builder
fmt.Fprintf(&b, `<svg viewBox="0 0 %.0f %.0f" width="100%%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Monthly Composer installs chart">`, totalW, totalH)
b.WriteString(`<style>g.bar .tip{opacity:0;transition:opacity .1s}g.bar:hover .tip{opacity:1}g.bar:hover rect{opacity:.9!important}</style>`)
// Y-axis tick lines and labels
for _, tick := range ticks {
y := padTop + chartH - (float64(tick)/float64(max))*chartH
// Grid line
fmt.Fprintf(&b, `<line x1="%.0f" y1="%.1f" x2="%.0f" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>`,
padLeft, y, totalW-padRight, y)
// Label
fmt.Fprintf(&b, `<text class="label" x="%.0f" y="%.1f" text-anchor="end" fill="#9ca3af" style="font-size:10px;font-family:sans-serif">%s</text>`,
padLeft-6, y+3.5, formatAxisLabel(tick))
}
for i, m := range data {
x := padLeft + float64(i)*(barW+barGap)
barH := (float64(m.Installs) / float64(max)) * chartH
y := padTop + chartH - barH
label := formatNumberComma(int64(m.Installs))
// Bar with rounded top corners
radius := 3.0
if barH < radius*2 {
radius = barH / 2
}
// Wrap bar + hover label in a group
b.WriteString(`<g class="bar">`)
ariaLabel := fmt.Sprintf("%s: %s installs", m.Month, label)
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%.1f" fill="#525ddc" style="opacity:.6;transition:opacity .15s" role="graphics-symbol" aria-label="%s"/>`,
x, y, barW, barH, radius, template.HTMLEscapeString(ariaLabel))
// Hover label above bar
tipY := y - 6
if tipY < 8 {
tipY = 8
}
fmt.Fprintf(&b, `<text class="tip tip-text" x="%.1f" y="%.1f" text-anchor="middle" fill="#525ddc" style="font-size:10px;font-weight:600;font-family:sans-serif">%s</text>`,
x+barW/2, tipY, label)
b.WriteString(`</g>`)
// X-axis label
labelX := x + barW/2
labelY := padTop + chartH + 16
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" text-anchor="middle" fill="#9ca3af" style="font-size:10px;font-family:sans-serif">%s</text>`,
labelX, labelY, m.Month)
}
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
// yAxisTicks returns 3-5 nice round tick values from 0 to max.
func yAxisTicks(max int) []int {
if max <= 0 {
return nil
}
// Find a nice step: 1, 2, 5, 10, 20, 50, 100, ...
target := max / 4
if target < 1 {
target = 1
}
mag := 1
for mag*10 <= target {
mag *= 10
}
var step int
if mag*2 >= target {
step = mag * 2
} else if mag*5 >= target {
step = mag * 5
} else {
step = mag * 10
}
var ticks []int
for v := step; v < max; v += step {
ticks = append(ticks, v)
}
// Always include a tick at or above max so bars don't exceed the top grid line
topTick := ((max + step - 1) / step) * step
ticks = append(ticks, topTick)
return ticks
}