diff --git a/go.mod b/go.mod index 91d863c..4e69cbb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/roots/wp-packages go 1.26.1 require ( + github.com/CloudyKit/jet/v6 v6.3.2 github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 @@ -21,6 +22,7 @@ require ( ) require ( + github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect diff --git a/go.sum b/go.sum index 6da8354..fb230e6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v6 v6.3.2 h1:BPaX0lnXTZ9TniICiiK/0iJqzeGJ2ibvB4DjAqLMBSM= +github.com/CloudyKit/jet/v6 v6.3.2/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= diff --git a/internal/http/handlers.go b/internal/http/handlers.go index efd8d10..bae0bc0 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -8,6 +8,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/CloudyKit/jet/v6" "os" "path/filepath" "slices" @@ -80,7 +82,7 @@ type versionRow struct { IsLatest bool } -func handleIndex(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleIndex(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { filters := publicFilters{ Search: r.URL.Query().Get("search"), @@ -126,23 +128,27 @@ func handleIndex(a *app.App, tmpl *templateSet) http.HandlerFunc { return } - render(w, r, tmpl.index, "layout", map[string]any{ + render(w, r, tmpl, "index.html", map[string]any{ "Packages": packages, "Filters": filters, "Page": page, "Total": total, "TotalPages": totalPages, - "Stats": stats, - "AppURL": a.Config.AppURL, - "CDNURL": a.Config.R2.CDNPublicURL, - "OGImage": ogImageURL(a.Config, "social/default.png"), - "JSONLD": jsonLDData, - "BlogPosts": a.Blog.Posts(), + "Pagination": buildPagination(page, totalPages, "#package-results", "#filter-form:top", + func(p int) string { return paginateURL(filters, p) }, + func(p int) string { return paginatePartialURL(filters, p) }, + ), + "Stats": stats, + "AppURL": a.Config.AppURL, + "CDNURL": a.Config.R2.CDNPublicURL, + "OGImage": ogImageURL(a.Config, "social/default.png"), + "JSONLD": jsonLDData, + "BlogPosts": a.Blog.Posts(), }) } } -func handleIndexPartial(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleIndexPartial(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { filters := publicFilters{ Search: r.URL.Query().Get("search"), @@ -168,20 +174,24 @@ func handleIndexPartial(a *app.App, tmpl *templateSet) http.HandlerFunc { totalPages := (total + perPage - 1) / perPage w.Header().Set("X-Robots-Tag", "noindex") - render(w, r, tmpl.indexPartial, "package-results", map[string]any{ + render(w, r, tmpl, "package_results.html", map[string]any{ "Packages": packages, "Filters": filters, "Page": page, "Total": total, "TotalPages": totalPages, + "Pagination": buildPagination(page, totalPages, "#package-results", "#filter-form:top", + func(p int) string { return paginateURL(filters, p) }, + func(p int) string { return paginatePartialURL(filters, p) }, + ), }) } } -func handleDocs(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleDocs(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") - render(w, r, tmpl.docs, "layout", map[string]any{ + render(w, r, tmpl, "docs.html", map[string]any{ "AppURL": a.Config.AppURL, "CDNURL": a.Config.R2.CDNPublicURL, "OGImage": ogImageURL(a.Config, "social/default.png"), @@ -189,10 +199,10 @@ func handleDocs(a *app.App, tmpl *templateSet) http.HandlerFunc { } } -func handleCompare(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleCompare(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") - render(w, r, tmpl.compare, "layout", map[string]any{ + render(w, r, tmpl, "compare.html", map[string]any{ "AppURL": a.Config.AppURL, "CDNURL": a.Config.R2.CDNPublicURL, "OGImage": ogImageURL(a.Config, "social/default.png"), @@ -202,7 +212,7 @@ func handleCompare(a *app.App, tmpl *templateSet) http.HandlerFunc { const untaggedPerPage = 20 -func handleUntagged(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleUntagged(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { @@ -227,7 +237,7 @@ func handleUntagged(a *app.App, tmpl *templateSet) http.HandlerFunc { _ = a.DB.QueryRowContext(r.Context(), "SELECT active_plugins FROM package_stats WHERE id = 1").Scan(&totalPlugins) w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") - render(w, r, tmpl.untagged, "layout", map[string]any{ + render(w, r, tmpl, "untagged.html", map[string]any{ "Packages": packages, "Filter": filter, "Search": search, @@ -237,14 +247,18 @@ func handleUntagged(a *app.App, tmpl *templateSet) http.HandlerFunc { "Total": int64(total), "TotalPlugins": totalPlugins, "TotalPages": totalPages, - "AppURL": a.Config.AppURL, - "CDNURL": a.Config.R2.CDNPublicURL, - "OGImage": ogImageURL(a.Config, "social/default.png"), + "Pagination": buildPagination(page, totalPages, "#untagged-results", "#untagged-form:top", + func(p int) string { return untaggedPaginateURL(filter, search, author, sort, p) }, + func(p int) string { return untaggedPaginatePartialURL(filter, search, author, sort, p) }, + ), + "AppURL": a.Config.AppURL, + "CDNURL": a.Config.R2.CDNPublicURL, + "OGImage": ogImageURL(a.Config, "social/default.png"), }) } } -func handleUntaggedPartial(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleUntaggedPartial(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { @@ -266,7 +280,7 @@ func handleUntaggedPartial(a *app.App, tmpl *templateSet) http.HandlerFunc { totalPages := (total + untaggedPerPage - 1) / untaggedPerPage w.Header().Set("X-Robots-Tag", "noindex") - render(w, r, tmpl.untaggedPartial, "untagged-results", map[string]any{ + render(w, r, tmpl, "untagged_results.html", map[string]any{ "Packages": packages, "Filter": filter, "Search": search, @@ -275,6 +289,10 @@ func handleUntaggedPartial(a *app.App, tmpl *templateSet) http.HandlerFunc { "Page": page, "Total": int64(total), "TotalPages": totalPages, + "Pagination": buildPagination(page, totalPages, "#untagged-results", "#untagged-form:top", + func(p int) string { return untaggedPaginateURL(filter, search, author, sort, p) }, + func(p int) string { return untaggedPaginatePartialURL(filter, search, author, sort, p) }, + ), }) } } @@ -324,10 +342,10 @@ func handleUntaggedAuthors(a *app.App) http.HandlerFunc { } } -func handleWordpressCore(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleWordpressCore(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") - render(w, r, tmpl.wordpressCore, "layout", map[string]any{ + render(w, r, tmpl, "wordpress_core.html", map[string]any{ "AppURL": a.Config.AppURL, "CDNURL": a.Config.R2.CDNPublicURL, "OGImage": ogImageURL(a.Config, "social/default.png"), @@ -336,7 +354,7 @@ func handleWordpressCore(a *app.App, tmpl *templateSet) http.HandlerFunc { } } -func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleDetail(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { pkgType := r.PathValue("type") name := r.PathValue("name") @@ -351,7 +369,7 @@ func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc { http.Redirect(w, r, "https://wp-packages.org/", http.StatusFound) } else { w.WriteHeader(http.StatusNotFound) - render(w, r, tmpl.notFound, "layout", map[string]any{"Gone": false, "CDNURL": a.Config.R2.CDNPublicURL}) + render(w, r, tmpl, "404.html", map[string]any{"Gone": false, "CDNURL": a.Config.R2.CDNPublicURL}) } return } @@ -429,7 +447,7 @@ func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc { return } - render(w, r, tmpl.detail, "layout", map[string]any{ + render(w, r, tmpl, "detail.html", map[string]any{ "Package": pkg, "Versions": versions, "MonthlyInstalls": monthlyInstalls, @@ -449,9 +467,9 @@ var logFiles = map[string]string{ "check-status": filepath.Join("storage", "logs", "check-status.log"), } -func handleAdminLogs(tmpl *templateSet) http.HandlerFunc { +func handleAdminLogs(tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - render(w, r, tmpl.adminLogs, "admin_layout", nil) + render(w, r, tmpl, "admin_logs.html", nil) } } @@ -937,7 +955,7 @@ type statusPageCheck struct { Changes []packages.StatusCheckChange } -func handleStatus(a *app.App, tmpl *templateSet) http.HandlerFunc { +func handleStatus(a *app.App, tmpl *jet.Set) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cutoff := time.Now().Add(-24 * time.Hour).UnixMilli() @@ -1022,10 +1040,12 @@ func handleStatus(a *app.App, tmpl *templateSet) http.HandlerFunc { "PackagesUpdated24h": packagesUpdated24h, "Deactivated24h": deactivated24h, "Reactivated24h": reactivated24h, + "AppURL": a.Config.AppURL, + "CDNURL": a.Config.R2.CDNPublicURL, } if len(statusBuilds) > 0 { data["LastBuildStartedAt"] = statusBuilds[0].StartedAt } - render(w, r, tmpl.status, "layout", data) + render(w, r, tmpl, "status.html", data) } } diff --git a/internal/http/router.go b/internal/http/router.go index de25e8c..bdbaa6e 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/CloudyKit/jet/v6" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/roots/wp-packages/internal/app" ) @@ -146,7 +147,7 @@ func NewRouter(a *app.App) http.Handler { // handler touched the response (checked via a context flag set by routeMarker), // replace the default body with the custom template. 405s and handler-generated // 404s pass through untouched. -func appHandler(mux *http.ServeMux, tmpl *templateSet, a *app.App, sitemapPackages http.HandlerFunc) http.Handler { +func appHandler(mux *http.ServeMux, tmpl *jet.Set, a *app.App, sitemapPackages http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Sitemap-packages prefix can't be expressed as a ServeMux pattern // (wildcards must be full path segments). @@ -163,7 +164,7 @@ func appHandler(mux *http.ServeMux, tmpl *templateSet, a *app.App, sitemapPackag // so rec.dispatched is false only when the mux itself returned 404/405/etc. if rec.code == http.StatusNotFound && !rec.dispatched { w.WriteHeader(http.StatusNotFound) - render(w, r, tmpl.notFound, "layout", map[string]any{"Gone": false, "CDNURL": a.Config.R2.CDNPublicURL}) + render(w, r, tmpl, "404.html", map[string]any{"Gone": false, "CDNURL": a.Config.R2.CDNPublicURL}) } }) } diff --git a/internal/http/templates.go b/internal/http/templates.go index 514c2d6..641c0bd 100644 --- a/internal/http/templates.go +++ b/internal/http/templates.go @@ -6,7 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" - "html/template" + "io" "io/fs" "net/http" "net/url" @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/CloudyKit/jet/v6" "github.com/roots/wp-packages/internal/telemetry" ) @@ -23,6 +24,27 @@ var templateFS embed.FS //go:embed all:static var staticFS embed.FS +// embedLoader implements jet.Loader for an embed.FS. +type embedLoader struct { + fs embed.FS + prefix string // e.g. "templates" +} + +func (l *embedLoader) Exists(templatePath string) bool { + name := l.prefix + templatePath // templatePath has leading / + f, err := l.fs.Open(name) + if err != nil { + return false + } + _ = f.Close() + return true +} + +func (l *embedLoader) Open(templatePath string) (io.ReadCloser, error) { + name := l.prefix + templatePath + return l.fs.Open(name) +} + // 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 { @@ -56,32 +78,34 @@ func assetPath(path string) string { 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 { +func loadTemplates(env string) *jet.Set { + loader := &embedLoader{fs: templateFS, prefix: "templates"} + var opts []jet.Option + if env != "production" { + opts = append(opts, jet.InDevelopmentMode()) + } + set := jet.NewSet(loader, opts...) + + // Plain functions + set.AddGlobal("assetPath", assetPath) + set.AddGlobal("formatNumber", formatNumber) + set.AddGlobal("formatNumberComma", formatNumberComma) + set.AddGlobal("paginate", paginateURL) + set.AddGlobal("paginatePartial", paginatePartialURL) + set.AddGlobal("untaggedPaginate", untaggedPaginateURL) + set.AddGlobal("untaggedPaginateP", untaggedPaginatePartialURL) + set.AddGlobal("formatCST", formatCST) + set.AddGlobal("timeAgo", timeAgo) + set.AddGlobal("timeAgoShort", timeAgoShort) + set.AddGlobal("formatDuration", formatDuration) + set.AddGlobal("pageRange", pageRange) + set.AddGlobal("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/" + }) + set.AddGlobal("wporgURL", func(composerName string) string { parts := strings.SplitN(composerName, "/", 2) if len(parts) != 2 { return "https://wordpress.org/" @@ -91,52 +115,38 @@ var funcMap = template.FuncMap{ section = "themes" } return "https://wordpress.org/" + section + "/" + parts[1] + "/" - }, -} + }) + set.AddGlobal("isProduction", func() bool { return env == "production" }) -type templateSet struct { - index *template.Template - indexPartial *template.Template - detail *template.Template - compare *template.Template - docs *template.Template - wordpressCore *template.Template - untagged *template.Template - untaggedPartial *template.Template - notFound *template.Template - adminLogs *template.Template - status *template.Template -} + // Functions returning raw HTML — use |raw in templates to bypass escaping + set.AddGlobal("jsonLD", renderJsonLD) + set.AddGlobal("installChart", renderInstallChart) -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"), - untagged: parse("templates/layout.html", "templates/untagged.html", "templates/untagged_results.html"), - untaggedPartial: parse("templates/untagged_results.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"), - } + return set } -func parse(files ...string) *template.Template { - return template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, files...)) -} +func render(w http.ResponseWriter, r *http.Request, set *jet.Set, name string, data map[string]any) { + vars := make(jet.VarMap) + // Defaults for variables used in layout that not every handler passes. + vars.Set("CDNURL", "") + vars.Set("AppURL", "") + vars.Set("OGImage", "") + vars.Set("Gone", false) + for k, v := range data { + vars.Set(k, v) + } + vars.Set("Path", r.URL.Path) -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 + tmpl, err := set.GetTemplate(name) + if err != nil { + captureError(r, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } + w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + if err := tmpl.Execute(w, vars, nil); err != nil { captureError(r, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } @@ -185,6 +195,53 @@ func formatAxisLabel(n int) string { return fmt.Sprintf("%d", n) } +type paginationPage struct { + Number int + URL string + PartialURL string +} + +type pagination struct { + Page int + TotalPages int + Target string + SwapTarget string + PrevURL string + PrevPartial string + NextURL string + NextPartial string + Pages []paginationPage +} + +func buildPagination(page, totalPages int, target, swapTarget string, urlFn, partialFn func(int) string) *pagination { + if totalPages <= 1 { + return nil + } + p := &pagination{ + Page: page, + TotalPages: totalPages, + Target: target, + SwapTarget: swapTarget, + } + if page > 1 { + p.PrevURL = urlFn(page - 1) + p.PrevPartial = partialFn(page - 1) + } + if page < totalPages { + p.NextURL = urlFn(page + 1) + p.NextPartial = partialFn(page + 1) + } + for _, n := range pageRange(page, totalPages) { + pg := paginationPage{Number: n} + if n > 0 { + pg.URL = urlFn(n) + pg.PartialURL = partialFn(n) + } + p.Pages = append(p.Pages, pg) + } + return p +} + type publicFilters struct { Search string Type string @@ -281,7 +338,7 @@ func untaggedPaginatePartialURL(filter, search, author, sort string, page int) s return "/untagged-partial?" + q } -func jsonLD(data any) template.HTML { +func renderJsonLD(data any) string { if data == nil { return "" } @@ -295,13 +352,13 @@ func jsonLD(data any) template.HTML { } out += `` } - return template.HTML(out) + return out } b, err := json.Marshal(data) if err != nil { return "" } - return template.HTML(``) + return `` } var cst = func() *time.Location { @@ -408,8 +465,8 @@ func pageRange(current, total int) []int { return result } -// installChart renders a server-side SVG bar chart for monthly install data. -func installChart(data []telemetry.MonthlyInstall) template.HTML { +// renderInstallChart renders a server-side SVG bar chart for monthly install data. +func renderInstallChart(data []telemetry.MonthlyInstall) string { if len(data) == 0 { return "" } @@ -484,7 +541,7 @@ func installChart(data []telemetry.MonthlyInstall) template.HTML { b.WriteString(``) ariaLabel := fmt.Sprintf("%s: %s installs", m.Month, label) fmt.Fprintf(&b, ``, - x, y, barW, barH, radius, template.HTMLEscapeString(ariaLabel)) + x, y, barW, barH, radius, htmlEscapeString(ariaLabel)) // Hover label above bar tipY := y - 6 if tipY < 8 { @@ -502,7 +559,19 @@ func installChart(data []telemetry.MonthlyInstall) template.HTML { } b.WriteString(``) - return template.HTML(b.String()) + return b.String() +} + +// htmlEscapeString escapes special HTML characters in a string. +func htmlEscapeString(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return replacer.Replace(s) } // yAxisTicks returns 3-5 nice round tick values from 0 to max. diff --git a/internal/http/templates/404.html b/internal/http/templates/404.html index 22173e0..ca85478 100644 --- a/internal/http/templates/404.html +++ b/internal/http/templates/404.html @@ -1,14 +1,14 @@ -{{template "layout" .}} -{{define "title"}}Page Not Found — WP Packages{{end}} -{{define "meta_seo"}} +{{extends "layout.html"}} +{{block title()}}Page Not Found — WP Packages{{end}} +{{block meta_seo()}} {{end}} -{{define "json_ld"}}{{end}} -{{define "og_meta"}}{{end}} -{{define "content"}} +{{block json_ld()}}{{end}} +{{block og_meta()}}{{end}} +{{block body()}}
-

{{if .Gone}}Package Removed{{else}}Page Not Found{{end}}

-

{{if .Gone}}This package has been removed from WP Packages.{{else}}The page you're looking for doesn't exist.{{end}}

+

{{if Gone}}Package Removed{{else}}Page Not Found{{end}}

+

{{if Gone}}This package has been removed from WP Packages.{{else}}The page you're looking for doesn't exist.{{end}}

Browse packages
-{{end}} \ No newline at end of file +{{end}} diff --git a/internal/http/templates/admin_layout.html b/internal/http/templates/admin_layout.html index 65cc704..74d96fd 100644 --- a/internal/http/templates/admin_layout.html +++ b/internal/http/templates/admin_layout.html @@ -1,15 +1,15 @@ -{{define "admin_layout"}} + -{{template "title" .}} — WP Packages Admin +{{yield title()}} — WP Packages Admin - +
-{{template "content" .}} +{{yield body()}}
-{{end}} + diff --git a/internal/http/templates/admin_logs.html b/internal/http/templates/admin_logs.html index ae417e6..301dc45 100644 --- a/internal/http/templates/admin_logs.html +++ b/internal/http/templates/admin_logs.html @@ -1,6 +1,6 @@ -{{template "admin_layout" .}} -{{define "title"}}Logs{{end}} -{{define "content"}} +{{extends "admin_layout.html"}} +{{block title()}}Logs{{end}} +{{block body()}}

Logs

diff --git a/internal/http/templates/compare.html b/internal/http/templates/compare.html index 58fd113..b03ead9 100644 --- a/internal/http/templates/compare.html +++ b/internal/http/templates/compare.html @@ -1,22 +1,23 @@ -{{template "layout" .}} -{{define "title"}}WP Packages vs WPackagist — Faster, Independent WordPress Composer Repository{{end}} -{{define "meta_seo"}} +{{extends "layout.html"}} +{{import "components.html"}} +{{block title()}}WP Packages vs WPackagist — Faster, Independent WordPress Composer Repository{{end}} +{{block meta_seo()}} -{{if .AppURL}}{{end}} +{{if AppURL}}{{end}} {{end}} -{{define "json_ld"}}{{end}} -{{define "og_meta"}} +{{block json_ld()}}{{end}} +{{block og_meta()}} -{{if .OGImage}}{{end}} -{{if .AppURL}}{{end}} +{{if OGImage}}{{end}} +{{if AppURL}}{{end}} -{{if .OGImage}}{{end}} +{{if OGImage}}{{end}} {{end}} -{{define "content"}} +{{block body()}}
@@ -201,13 +202,7 @@

Support independent WordPress t

Migrating from WPackagist

Switching from WPackagist takes one command. Use the migration script to automatically update your composer.json:

-
-$ -curl -sO https://raw.githubusercontent.com/roots/wp-packages/main/scripts/migrate-from-wpackagist.sh && bash migrate-from-wpackagist.sh - -
+{{yield shell(command="curl -sO https://raw.githubusercontent.com/roots/wp-packages/main/scripts/migrate-from-wpackagist.sh && bash migrate-from-wpackagist.sh", class="mb-8")}}

Manually migrate

@@ -215,33 +210,21 @@

Manually migrate

1

Remove WPackagist packages:

-
-$ -composer remove wpackagist-plugin/woocommerce - -
+{{yield shell(command="composer remove wpackagist-plugin/woocommerce")}}
2

Remove the WPackagist repository and add WP Packages:

-
-$ -composer config --unset repositories.wpackagist && composer config repositories.wp-packages composer https://repo.wp-packages.org - -
+{{yield shell(command="composer config --unset repositories.wpackagist && composer config repositories.wp-packages composer https://repo.wp-packages.org")}}
3

Require packages with the new naming:

-
-$ -composer require wp-plugin/woocommerce - -
+{{yield shell(command="composer require wp-plugin/woocommerce")}}
@@ -249,4 +232,4 @@

Manually migrate

-{{end}} +{{end}} \ No newline at end of file diff --git a/internal/http/templates/components.html b/internal/http/templates/components.html new file mode 100644 index 0000000..23aec42 --- /dev/null +++ b/internal/http/templates/components.html @@ -0,0 +1,37 @@ +{{block shell(command, variant="default", class="")}} +
+$ +{{command}} + +
+{{end}} + +{{block notice(variant="warning", class="")}} +
+{{if variant == "caution"}} +{{else if variant == "warning"}} +{{else if variant == "tip"}} +{{else if variant == "important"}} +{{else}} +{{end}} +{{yield content}} +
+{{end}} + +{{block input(icon, name, value="", placeholder="", type="text", showShortcut=false, id="", autocomplete="")}} +{{if icon == "search"}}{{else if icon == "user"}}{{else if icon == "filter"}}{{else if icon == "sort"}}{{end}} + +{{if showShortcut}}{{end}} +{{end}} + +{{block pagination(pager, class="")}} +{{if pager}} +
+{{if pager.PrevURL}}Previous{{else}}Previous{{end}} +{{range _, pg := pager.Pages}}{{if pg.Number == 0}}...{{else if pg.Number == pager.Page}}{{pg.Number}}{{else}}{{pg.Number}}{{end}}{{end}} +{{if pager.NextURL}}Next{{else}}Next{{end}} +
+{{end}} +{{end}} diff --git a/internal/http/templates/detail.html b/internal/http/templates/detail.html index a54c616..651d4ad 100644 --- a/internal/http/templates/detail.html +++ b/internal/http/templates/detail.html @@ -1,50 +1,44 @@ -{{template "layout" .}} -{{define "title"}}{{if .Package.DisplayName}}{{.Package.DisplayName}}{{else}}{{.Package.Name}}{{end}} — WP Packages{{end}} -{{define "meta_seo"}} - -{{if .AppURL}}{{end}} +{{extends "layout.html"}} +{{import "components.html"}} +{{block title()}}{{if Package.DisplayName}}{{Package.DisplayName}}{{else}}{{Package.Name}}{{end}} — WP Packages{{end}} +{{block meta_seo()}} + +{{if AppURL}}{{end}} {{end}} -{{define "json_ld"}}{{jsonLD .JSONLD}}{{end}} -{{define "og_meta"}} - - -{{if .OGImage}}{{end}} -{{if .AppURL}}{{end}} +{{block json_ld()}}{{jsonLD(JSONLD)|raw}}{{end}} +{{block og_meta()}} + + +{{if OGImage}}{{end}} +{{if AppURL}}{{end}} - - -{{if .OGImage}}{{end}} + + +{{if OGImage}}{{end}} {{end}} -{{define "content"}} +{{block body()}}
-{{if .Untagged}} -
- -
-{{if .TrunkOnly}}

No tagged releases in SVN

-

This plugin releases exclusively via SVN trunk. Install with dev-trunk — Composer will pin to a specific SVN revision in your lock file. or see all untagged plugins.

+{{if Untagged}} +{{yield notice(variant="warning", class="mb-6") content}} +
+{{if TrunkOnly}}

No tagged releases in SVN

+

This plugin releases exclusively via SVN trunk. Install with dev-trunk — Composer will pin to a specific SVN revision in your lock file. or see all untagged plugins.

{{else}}

Latest version not tagged in SVN

-

WordPress.org reports version {{.Package.WporgVersion}} but it doesn't match any tagged release, so the latest version isn't available as a tagged release via Composer. or see all untagged plugins.

+

WordPress.org reports version {{Package.WporgVersion}} but it doesn't match any tagged release, so the latest version isn't available as a tagged release via Composer. or see all untagged plugins.

Install latest from trunk

Install with dev-trunk to get the latest code. Composer will pin to a specific SVN revision in your lock file.

-
-$ -composer require wp-{{.Package.Type}}/{{.Package.Name}}:dev-trunk - -
+{{yield shell(command="composer require wp-"+Package.Type+"/"+Package.Name+":dev-trunk", variant="amber")}}
{{end}}
-
+{{end}} {{end}}
@@ -52,16 +46,16 @@
-

{{if .Package.DisplayName}}{{.Package.DisplayName}}{{else}}{{.Package.Name}}{{end}}

-{{.Package.Type}} -{{if .Package.CurrentVersion}}v{{.Package.CurrentVersion}}{{end}} +

{{if Package.DisplayName}}{{Package.DisplayName}}{{else}}{{Package.Name}}{{end}}

+{{Package.Type}} +{{if Package.CurrentVersion}}v{{Package.CurrentVersion}}{{end}}
-

wp-{{.Package.Type}}/{{.Package.Name}}

+

wp-{{Package.Type}}/{{Package.Name}}

-
+
$ -composer require wp-{{.Package.Type}}/{{.Package.Name}}{{if .TrunkOnly}}:dev-trunk{{end}} +composer require wp-{{Package.Type}}/{{Package.Name}}{{if TrunkOnly}}:dev-trunk{{end}} Click to copy
@@ -69,26 +63,26 @@

{{if .Package.DisplayName}}{{.Packa
- +
-{{if .Package.Description}}

{{.Package.Description}}

+{{if Package.Description}}

{{Package.Description}}

{{else}}

No description available.

{{end}}
-{{if .MonthlyInstalls}} +{{if MonthlyInstalls}}

Composer Installs

-{{installChart .MonthlyInstalls}} +{{installChart(MonthlyInstalls)|raw}}
{{end}}
@@ -114,16 +108,16 @@

Composer Installs

Package Info

-{{if .Package.CurrentVersion}}
Version{{.Package.CurrentVersion}}
{{end}} -
Active Installs{{formatNumber .Package.ActiveInstalls}}
-
Composer Installs{{formatNumber .Package.WpPackagesInstallsTotal}}
-{{if .Package.Author}}
Author{{.Package.Author}}
{{end}} +{{if Package.CurrentVersion}}
Version{{Package.CurrentVersion}}
{{end}} +
Active Installs{{formatNumber(Package.ActiveInstalls)}}
+
Composer Installs{{formatNumber(Package.WpPackagesInstallsTotal)}}
+{{if Package.Author}}
Author{{Package.Author}}
{{end}}
- + WordPress.org -{{if .Package.Homepage}} +{{if Package.Homepage}} Homepage{{end}}
diff --git a/internal/http/templates/docs.html b/internal/http/templates/docs.html index 2384af7..1f9a7aa 100644 --- a/internal/http/templates/docs.html +++ b/internal/http/templates/docs.html @@ -1,22 +1,23 @@ -{{template "layout" .}} -{{define "title"}}Documentation — WP Packages{{end}} -{{define "meta_seo"}} +{{extends "layout.html"}} +{{import "components.html"}} +{{block title()}}Documentation — WP Packages{{end}} +{{block meta_seo()}} -{{if .AppURL}}{{end}} +{{if AppURL}}{{end}} {{end}} -{{define "json_ld"}}{{end}} -{{define "og_meta"}} +{{block json_ld()}}{{end}} +{{block og_meta()}} -{{if .OGImage}}{{end}} -{{if .AppURL}}{{end}} +{{if OGImage}}{{end}} +{{if AppURL}}{{end}} -{{if .OGImage}}{{end}} +{{if OGImage}}{{end}} {{end}} -{{define "content"}} +{{block body()}}
@@ -35,18 +36,11 @@

Add the WP Packages repository to your project:

-
-$ -composer config repositories.wp-packages composer https://repo.wp-packages.org - -
+{{yield shell(command="composer config repositories.wp-packages composer https://repo.wp-packages.org", class="mb-4")}}

Every active plugin and theme from the WordPress.org directory is available as a Composer package. Search all packages →

-
- -Some WordPress plugins have a latest version on WordPress.org that isn't tagged in SVN. These can be installed via dev-trunk. Learn more on our Untagged Plugins page. -
+{{yield notice(class="mb-12") content}} +Some WordPress plugins have a latest version on WordPress.org that isn't tagged in SVN. These can be installed via dev-trunk. Learn more on our Untagged Plugins page. +{{end}}

Package naming

@@ -117,46 +111,28 @@

WordPress c

Migrating from WPackagist

See how WP Packages compares to WPackagist →

Switching from WPackagist takes one command. Use the migration script to automatically update your composer.json:

-
-$ -curl -sO https://raw.githubusercontent.com/roots/wp-packages/main/scripts/migrate-from-wpackagist.sh && bash migrate-from-wpackagist.sh - -
+{{yield shell(command="curl -sO https://raw.githubusercontent.com/roots/wp-packages/main/scripts/migrate-from-wpackagist.sh && bash migrate-from-wpackagist.sh", class="mb-8")}}

Manually migrate

1

Remove WPackagist packages:

-
-$ -composer remove wpackagist-plugin/woocommerce - -
+{{yield shell(command="composer remove wpackagist-plugin/woocommerce")}}
2

Remove the WPackagist repository and add WP Packages:

-
-$ -composer config --unset repositories.wpackagist && composer config repositories.wp-packages composer https://repo.wp-packages.org - -
+{{yield shell(command="composer config --unset repositories.wpackagist && composer config repositories.wp-packages composer https://repo.wp-packages.org")}}
3

Require packages with the new naming:

-
-$ -composer require wp-plugin/woocommerce - -
+{{yield shell(command="composer require wp-plugin/woocommerce")}}
@@ -202,11 +178,7 @@

GET /api/stats

GET /api/stats/packages/{type}/{name}

Returns monthly install history for a specific package (up to 36 months). The type can be wp-plugin or wp-theme.

-
-$ -curl https://wp-packages.org/api/stats/packages/wp-plugin/akismet - -
+{{yield shell(command="curl https://wp-packages.org/api/stats/packages/wp-plugin/akismet", class="mb-3")}}
Response
[
diff --git a/internal/http/templates/index.html b/internal/http/templates/index.html
index b6decc6..05c3d22 100644
--- a/internal/http/templates/index.html
+++ b/internal/http/templates/index.html
@@ -1,23 +1,24 @@
-{{template "layout" .}}
-{{define "title"}}WP Packages — WordPress Packages for Composer | WPackagist Alternative{{end}}
-{{define "meta_seo"}}
+{{extends "layout.html"}}
+{{import "components.html"}}
+{{block title()}}WP Packages — WordPress Packages for Composer | WPackagist Alternative{{end}}
+{{block meta_seo()}}
 
-{{if .AppURL}}{{end}}
-{{if and .Page (gt .Page 1)}}{{end}}
+{{if AppURL}}{{end}}
+{{if Page && Page > 1}}{{end}}
 {{end}}
-{{define "json_ld"}}{{jsonLD .JSONLD}}{{end}}
-{{define "og_meta"}}
+{{block json_ld()}}{{jsonLD(JSONLD)|raw}}{{end}}
+{{block og_meta()}}
 
 
-{{if .OGImage}}{{end}}
-{{if .AppURL}}{{end}}
+{{if OGImage}}{{end}}
+{{if AppURL}}{{end}}
 
 
 
 
-{{if .OGImage}}{{end}}
+{{if OGImage}}{{end}}
 {{end}}
-{{define "content"}}
+{{block body()}}
 
@@ -31,15 +32,15 @@

A 17x fas
-
{{formatNumber .Stats.PluginInstalls}}
+
{{formatNumber(Stats.PluginInstalls)}}
Plugin Installs
-
{{formatNumber .Stats.ThemeInstalls}}
+
{{formatNumber(Stats.ThemeInstalls)}}
Theme Installs
-
{{formatNumber .Stats.RootsDownloads}}
+
{{formatNumber(Stats.RootsDownloads)}}
@@ -50,9 +51,7 @@

A 17x fas
- - - +{{yield input(icon="search", type="search", name="search", value=Filters.Search, placeholder="Search packages...", showShortcut=true)}}
@@ -60,18 +59,18 @@

A 17x fas

@@ -79,27 +78,26 @@

A 17x fas

-{{template "package-results" .}} +{{include "package_results.html"}}
-
- -Some WordPress plugins have a latest version on WordPress.org that isn't tagged in SVN. These can be installed via dev-trunk. Learn more on our Untagged Plugins page. -
+{{yield notice() content}} +Some WordPress plugins have a latest version on WordPress.org that isn't tagged in SVN. These can be installed via dev-trunk. Learn more on our Untagged Plugins page. +{{end}}

-{{if .BlogPosts}} +{{if BlogPosts}}

Latest updates

{{end}} -{{end}} +{{end}} \ No newline at end of file diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index 1eaef03..c35ea5a 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -1,21 +1,21 @@ -{{define "layout"}} + -{{template "title" .}} -{{template "meta_seo" .}} +{{yield title()}} +{{yield meta_seo()}} -{{template "og_meta" .}} -{{template "json_ld" .}} - - -{{if .CDNURL}}{{end}} - +{{yield og_meta()}} +{{yield json_ld()}} + + +{{if CDNURL}}{{end}} + -{{if isProduction}}{{end}} +{{if isProduction()}}{{end}} -{{end}} + diff --git a/internal/http/templates/package_results.html b/internal/http/templates/package_results.html index 456ff5b..c242bb7 100644 --- a/internal/http/templates/package_results.html +++ b/internal/http/templates/package_results.html @@ -1,7 +1,7 @@ -{{define "package-results"}} +{{import "components.html"}} -{{if gt .TotalPages 1}} -
-{{if gt .Page 1}}Previous{{else}}Previous{{end}} -{{range pageRange .Page .TotalPages}}{{if eq . 0}}...{{else if eq . $.Page}}{{.}}{{else}}{{.}}{{end}}{{end}} -{{if lt .Page .TotalPages}}Next{{else}}Next{{end}} -
-{{end}} - -{{end}} +{{yield pagination(pager=Pagination, class="pb-8")}} diff --git a/internal/http/templates/status.html b/internal/http/templates/status.html index 722b55f..95d10f1 100644 --- a/internal/http/templates/status.html +++ b/internal/http/templates/status.html @@ -1,34 +1,34 @@ -{{template "layout" .}} -{{define "title"}}Status — WP Packages{{end}} -{{define "meta_seo"}} +{{extends "layout.html"}} +{{block title()}}Status — WP Packages{{end}} +{{block meta_seo()}} -{{if .AppURL}}{{end}} +{{if AppURL}}{{end}} {{end}} -{{define "json_ld"}}{{end}} -{{define "og_meta"}} +{{block json_ld()}}{{end}} +{{block og_meta()}} -{{if .AppURL}}{{end}} +{{if AppURL}}{{end}} {{end}} -{{define "content"}} +{{block body()}}

Status

Packages

-

{{formatNumberComma .Stats.TotalPackages}}

-

{{formatNumberComma .Stats.ActivePlugins}} plugins · {{formatNumberComma .Stats.ActiveThemes}} themes

+

{{formatNumberComma(Stats.TotalPackages)}}

+

{{formatNumberComma(Stats.ActivePlugins)}} plugins · {{formatNumberComma(Stats.ActiveThemes)}} themes

Composer Installs

-

{{formatNumberComma .Stats.TotalInstalls}}

-

{{formatNumberComma .Stats.Installs30d}} in last 30d{{if .Stats.StatsUpdatedAt}} · updated {{formatCST .Stats.StatsUpdatedAt}}{{end}}

+

{{formatNumberComma(Stats.TotalInstalls)}}

+

{{formatNumberComma(Stats.Installs30d)}} in last 30d{{if Stats.StatsUpdatedAt}} · updated {{formatCST(Stats.StatsUpdatedAt)}}{{end}}

Activity (24h)

-

{{formatNumberComma .PackagesUpdated24h}} updated

-

{{.Deactivated24h}} deactivated · {{.Reactivated24h}} reactivated

+

{{formatNumberComma(PackagesUpdated24h)}} updated

+

{{Deactivated24h}} deactivated · {{Reactivated24h}} reactivated

@@ -49,8 +49,8 @@

Status

-{{if .LastBuildStartedAt}}

Builds run every 5 minutes. Last build: {{timeAgo .LastBuildStartedAt}}

{{end}} -{{if not .Builds}} +{{if LastBuildStartedAt}}

Builds run every 5 minutes. Last build: {{timeAgo(LastBuildStartedAt)}}

{{end}} +{{if !Builds}}

No builds found.

{{else}}
@@ -68,40 +68,40 @@

Status

-{{range .Builds}} - -{{.ID}} -{{formatCST .StartedAt}} -{{if eq .Status "completed"}}{{.PackagesTotal}}{{else if eq .Status "running"}}{{end}} -{{if eq .Status "completed"}}{{.PackagesChanged}}{{else if eq .Status "running"}}{{end}} -{{if eq .Status "completed"}}{{.ArtifactCount}}{{else if eq .Status "running"}}{{end}} +{{range _, build := Builds}} + +{{build.ID}} +{{formatCST(build.StartedAt)}} +{{if build.Status == "completed"}}{{build.PackagesTotal}}{{else if build.Status == "running"}}{{end}} +{{if build.Status == "completed"}}{{build.PackagesChanged}}{{else if build.Status == "running"}}{{end}} +{{if build.Status == "completed"}}{{build.ArtifactCount}}{{else if build.Status == "running"}}{{end}} -{{- if .IsCurrent}}Current -{{- else if eq .Status "running"}}Running -{{- else if eq .Status "cancelled"}}Cancelled -{{- else if eq .Status "failed"}}Failed -{{- else}}{{.Status}}{{end}} +{{- if build.IsCurrent}}Current +{{- else if build.Status == "running"}}Running +{{- else if build.Status == "cancelled"}}Cancelled +{{- else if build.Status == "failed"}}Failed +{{- else}}{{build.Status}}{{end}} -{{if .DurationSeconds}}{{formatDuration .DurationSeconds}}{{end}} -{{if or .DiscoverSeconds .UpdateSeconds .BuildSeconds .DeploySeconds .R2UploadSeconds}} -{{- if .DiscoverSeconds}}D:{{formatDuration .DiscoverSeconds}} {{end}}{{- if .UpdateSeconds}}U:{{formatDuration .UpdateSeconds}} {{end}}{{- if .BuildSeconds}}B:{{formatDuration .BuildSeconds}} {{end}}{{- if .DeploySeconds}}Dp:{{formatDuration .DeploySeconds}} {{end}}{{- if .R2UploadSeconds}}R2:{{formatDuration .R2UploadSeconds}}{{end}} +{{if build.DurationSeconds}}{{formatDuration(build.DurationSeconds)}}{{end}} +{{if build.DiscoverSeconds || build.UpdateSeconds || build.BuildSeconds || build.DeploySeconds || build.R2UploadSeconds}} +{{- if build.DiscoverSeconds}}D:{{formatDuration(build.DiscoverSeconds)}} {{end}}{{- if build.UpdateSeconds}}U:{{formatDuration(build.UpdateSeconds)}} {{end}}{{- if build.BuildSeconds}}B:{{formatDuration(build.BuildSeconds)}} {{end}}{{- if build.DeploySeconds}}Dp:{{formatDuration(build.DeploySeconds)}} {{end}}{{- if build.R2UploadSeconds}}R2:{{formatDuration(build.R2UploadSeconds)}}{{end}} {{end}} -{{if .R2SyncedAt}}Synced +{{if build.R2SyncedAt}}Synced {{else}}{{end}} -{{if .Changes}} +{{if build.Changes}} -{{range .Changes}} +{{range _, change := build.Changes}} - - - + + + {{end}}
{{if eq .Action "delete"}}deleted{{else}}updated{{end}}{{if eq .Action "delete"}}{{.PackageName}}{{else}}{{.PackageName}}{{end}}{{if eq .Action "delete"}}View on WordPress.org{{end}}{{if change.Action == "delete"}}deleted{{else}}updated{{end}}{{if change.Action == "delete"}}{{change.PackageName}}{{else}}{{change.PackageName}}{{end}}{{if change.Action == "delete"}}View on WordPress.org{{end}}
@@ -117,7 +117,7 @@

Status

-{{end}} +{{end}} \ No newline at end of file