rastrillo / abono Public

abono: partials on message keys, Templates() and Catalog()

Summary

Six partials (status, plan card, plan picker, paywall, subscribe and billing buttons) plus a price helper, written on message keys only, with Templates() and Catalog() to carry them. locales/en.toml holds a comment line: the approved English lands in Task 8b.
Agent notes
Tests run against a made-up English catalog in the test file, merged the way an app merges Catalog(). The key-coverage and em-dash tests skip until en.toml has keys. Dates print as year-month-day because core has no template function that formats a date in the reader's locale (dateWords is the date-parser vocabulary). go.mod gains rastrillo/money, which ui now needs.
claude agent, operated by paul@keymail.dev c1235bde3a84b46116274fe9dd1628a3e71238c0 parent 23f3980
15 files changed, +829 −0
  • catalog.go +115 −0
  • catalog_test.go +49 −0
  • describe.go +4 −0
  • go.mod +1 −0
  • go.sum +2 −0
  • locales/en.toml +1 −0
  • templates.go +40 −0
  • templates/abono-billing-button.html +8 −0
  • templates/abono-paywall.html +17 −0
  • templates/abono-plan-card.html +16 −0
  • templates/abono-plan-picker.html +14 −0
  • templates/abono-price.html +8 −0
  • templates/abono-status.html +18 −0
  • templates/abono-subscribe-button.html +16 −0
  • templates_test.go +520 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/rastrillo/rastrillo/money v0.1.0
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.609s
ok  	amadan.net/rastrillo/abono/abonotest	0.049s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.017s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.007s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	5.010s
ok  	amadan.net/rastrillo/abono/abonotest	1.367s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.059s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.017s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/catalog.go b/catalog.go
new file mode 100644
index 0000000..0550bdb
--- /dev/null
+++ b/catalog.go
@@ -0,0 +1,115 @@
+package abono
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "strconv"
+ "strings"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+//go:embed locales/*.toml
+var localesFS embed.FS
+
+// english is locales/en.toml, decoded once. A malformed file is a build
+// bug, so it panics at start-up, as rastrillo's own base catalogs do.
+var english = func() rastrillo.Catalog {
+ data, err := fs.ReadFile(localesFS, "locales/en.toml")
+ if err != nil {
+ panic(fmt.Sprintf("abono: catalog en: %v", err))
+ }
+ m, err := decodeCatalog(data)
+ if err != nil {
+ panic(fmt.Sprintf("abono: catalog en: %v", err))
+ }
+ return m
+}()
+
+// Catalog is abono's English, the "abono.*" message keys the partials and
+// the starter pages use. It is a copy: change it freely.
+//
+// Merge it into the base catalog you give rastrillo.NewLocales, which sits
+// under every locale's own catalog and above nothing but the key itself:
+//
+// base := rastrillo.BaseCatalog()
+// for k, v := range abono.Catalog() {
+// base[k] = v
+// }
+// locs, err := rastrillo.NewLocales(codes, "en", base, appFS)
+//
+// An app overrides any key, or translates it, in its own
+// locales/<code>.toml, where an "abono.*" key wins over this one. A
+// locale the app has not translated reads the English.
+func Catalog() rastrillo.Catalog {
+ out := make(rastrillo.Catalog, len(english))
+ for k, v := range english {
+ out[k] = v
+ }
+ return out
+}
+
+// decodeCatalog reads rastrillo's flat catalog format: one key = "value"
+// per line, # comments and blank lines. rastrillo's own decoder is
+// internal to it, so this is the same subset: no tables, a dot in a key
+// is just a character, a value is a basic string (with escapes) or a
+// literal one.
+func decodeCatalog(data []byte) (rastrillo.Catalog, error) {
+ out := rastrillo.Catalog{}
+ for i, raw := range strings.Split(string(data), "\n") {
+ line := strings.TrimSpace(raw)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, rest, ok := strings.Cut(line, "=")
+ key = strings.TrimSpace(key)
+ if !ok || key == "" || strings.ContainsAny(key, " \t[]\"'") {
+ return nil, fmt.Errorf("line %d: want key = \"value\"", i+1)
+ }
+ val, err := catalogValue(strings.TrimSpace(rest))
+ if err != nil {
+ return nil, fmt.Errorf("line %d: %w", i+1, err)
+ }
+ if _, dup := out[key]; dup {
+ return nil, fmt.Errorf("line %d: duplicate key %q", i+1, key)
+ }
+ out[key] = val
+ }
+ return out, nil
+}
+
+// catalogValue reads a quoted string, then allows only a # comment after it.
+func catalogValue(s string) (string, error) {
+ var end int
+ switch {
+ case strings.HasPrefix(s, `"`):
+ end = -1
+ for i := 1; i < len(s); i++ {
+ if s[i] == '\\' {
+ i++
+ } else if s[i] == '"' {
+ end = i
+ break
+ }
+ }
+ case strings.HasPrefix(s, "'"):
+ end = strings.Index(s[1:], "'") + 1
+ if end == 0 {
+ end = -1
+ }
+ default:
+ return "", fmt.Errorf("a value must be a quoted string")
+ }
+ if end < 0 {
+ return "", fmt.Errorf("unterminated string")
+ }
+ if tail := strings.TrimSpace(s[end+1:]); tail != "" && !strings.HasPrefix(tail, "#") {
+ return "", fmt.Errorf("unexpected %q after the string", tail)
+ }
+ if s[0] == '\'' {
+ return s[1:end], nil
+ }
+ // TOML's basic-string escapes are a subset of Go's.
+ return strconv.Unquote(s[:end+1])
+}
diff --git a/catalog_test.go b/catalog_test.go
new file mode 100644
index 0000000..e8b1fd4
--- /dev/null
+++ b/catalog_test.go
@@ -0,0 +1,49 @@
+package abono
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestDecodeCatalog(t *testing.T) {
+ got, err := decodeCatalog([]byte("# a comment\n\nabono.a = \"One \\\"two\\\" {x}\" # trailing\nabono.b = 'lit\\eral'\r\nabono.c-d_e = \"é\"\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]string{"abono.a": `One "two" {x}`, "abono.b": `lit\eral`, "abono.c-d_e": "é"}
+ if !reflect.DeepEqual(map[string]string(got), want) {
+ t.Errorf("got %v, want %v", got, want)
+ }
+}
+
+func TestDecodeCatalogRefuses(t *testing.T) {
+ for name, in := range map[string]string{
+ "table": "[abono]\n",
+ "no equals": "abono.a\n",
+ "bare value": "abono.a = 3\n",
+ "unterminated": "abono.a = \"x\n",
+ "trailing": "abono.a = \"x\" y\n",
+ "duplicate": "abono.a = \"x\"\nabono.a = \"y\"\n",
+ "empty key": " = \"x\"\n",
+ "bad escape": "abono.a = \"\\q\"\n",
+ } {
+ if _, err := decodeCatalog([]byte(in)); err == nil {
+ t.Errorf("%s: accepted %q", name, in)
+ }
+ }
+}
+
+// The shipped file decodes, and Catalog hands out a copy.
+func TestCatalogIsACopyOfTheShippedFile(t *testing.T) {
+ c := Catalog()
+ for k := range c {
+ if !strings.HasPrefix(k, "abono.") {
+ t.Errorf("key %q is not in abono's namespace", k)
+ }
+ }
+ c["abono.test.sentinel"] = "x"
+ if _, leaked := Catalog()["abono.test.sentinel"]; leaked {
+ t.Error("Catalog shares its table with the caller")
+ }
+}
diff --git a/describe.go b/describe.go
index 82fabe3..4cefb1d 100644
--- a/describe.go
+++ b/describe.go
@@ -71,3 +71,7 @@ func copyTime(t *time.Time) *time.Time {
c := *t
return &c
}
+
+// Describe is Describe(h, now), as a method so a template can call it:
+// {{.Holding.Describe .Now}}.
+func (h Holding) Describe(now time.Time) Status { return Describe(h, now) }
diff --git a/go.mod b/go.mod
index 1584a21..76d622e 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
)
require (
+ amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index f235fa5..b01b291 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1 h1:uGwLHZR1zF5yibK7rPI6o9Lod1aMUw1yfKMdK0Fz+sM=
amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1/go.mod h1:jSSeydqEvlm/fU3hhWoXFnyEaDgroR8LPRZSo9ZhhIM=
+amadan.net/rastrillo/rastrillo/money v0.1.0 h1:MxU4oqNrW8abxRTkgZBv24OYNpFnvonajNqeLIzmmaM=
+amadan.net/rastrillo/rastrillo/money v0.1.0/go.mod h1:cmgkjBEIQh1pFSYvQk0UU6oPYS8kSDypdT9+t4Flx0U=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
diff --git a/locales/en.toml b/locales/en.toml
new file mode 100644
index 0000000..b4c601a
--- /dev/null
+++ b/locales/en.toml
@@ -0,0 +1 @@
+# The approved English for abono's message keys lands here in Task 8b.
diff --git a/templates.go b/templates.go
new file mode 100644
index 0000000..39d89eb
--- /dev/null
+++ b/templates.go
@@ -0,0 +1,40 @@
+package abono
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+// Templates is abono's partials, rooted so callers parse "*.html", as
+// ui.Templates is. Parse them after ui's, with ui.Funcs:
+//
+// tmpl := template.New("").Funcs(ui.Funcs())
+// tmpl = template.Must(tmpl.ParseFS(ui.Templates(), "*.html"))
+// tmpl = template.Must(tmpl.ParseFS(abono.Templates(), "*.html"))
+//
+// The partials:
+//
+// abono-status {Holding, Now}: the holding as one status pill
+// abono-plan-card {Offer, Interval, Action, Code}: one plan at one interval
+// abono-plan-picker {Offers, Action}: the plans on sale, by interval
+// abono-paywall {Offers, SignedIn, SigninURL, Action, PlansURL}
+// abono-subscribe-button {Action, Plan, Interval, Label, Code}: a POST form
+// abono-billing-button {Action}: a POST form
+//
+// Each finds the request's localizer under "L" in its data, as ui's
+// partials do; add it with withL when calling one from another template.
+// The words are message keys, "abono.*": merge Catalog() into the base
+// catalog (see Catalog).
+//
+// The partials carry no stylesheet. They use core's attributes and
+// tokens, so they follow the app's theme, light and dark.
+func Templates() fs.FS {
+ sub, err := fs.Sub(templatesFS, "templates")
+ if err != nil {
+ panic(err) // templates/ is embedded above; this cannot fail
+ }
+ return sub
+}
diff --git a/templates/abono-billing-button.html b/templates/abono-billing-button.html
new file mode 100644
index 0000000..cc5bf16
--- /dev/null
+++ b/templates/abono-billing-button.html
@@ -0,0 +1,8 @@
+{{/* abono-billing-button — a POST form that opens Stripe's billing
+ portal, where a subscriber changes their card or cancels. A POST
+ because the handler mints a portal session.
+
+ Keys:
+ Action string, required — where the form posts
+ L *ui.Localizer */}}
+{{define "abono-billing-button"}}{{$L := localizer $}}<form rst-form-actions method="post" action="{{.Action}}"><button type="submit" rst-btn>{{T $L "abono.button.manage"}}</button></form>{{end}}
diff --git a/templates/abono-paywall.html b/templates/abono-paywall.html
new file mode 100644
index 0000000..0c4c710
--- /dev/null
+++ b/templates/abono-paywall.html
@@ -0,0 +1,17 @@
+{{/* abono-paywall — what stands where locked content would be: why it
+ is locked, and the way in. A reader who is not signed in is asked to
+ sign in (they cannot buy as no one); a signed-in reader sees the
+ plans, or a link to them.
+
+ The block is a padded box when it holds only words and a button; with
+ the picker inline the plan boxes are the boxes, and it is a plain section.
+
+ Keys:
+ Offers []abono.Offer, required — from Service.Offers
+ SignedIn bool, required
+ SigninURL string — where "sign in" goes; used when not SignedIn
+ Action string — where the buy forms post; used when SignedIn
+ PlansURL string, optional — when set, a signed-in reader gets a
+ "see plans" link there instead of the picker inline
+ L *ui.Localizer */}}
+{{define "abono-paywall"}}{{$L := localizer $}}<section{{if or (not .SignedIn) (opt . "PlansURL")}} rst-box{{end}}><h2>{{T $L "abono.paywall.title"}}</h2><p>{{T $L "abono.paywall.body"}}</p>{{if .SignedIn}}{{with opt . "PlansURL"}}<p><a rst-btn="primary" href="{{.}}">{{T $L "abono.button.see_plans"}}</a></p>{{else}}{{template "abono-plan-picker" dict "L" $L "Offers" $.Offers "Action" $.Action}}{{end}}{{else}}<p>{{T $L "abono.paywall.signin_prompt"}}</p><p><a rst-btn="primary" href="{{.SigninURL}}">{{T $L "abono.paywall.signin"}}</a></p>{{end}}</section>{{end}}
diff --git a/templates/abono-plan-card.html b/templates/abono-plan-card.html
new file mode 100644
index 0000000..10486fa
--- /dev/null
+++ b/templates/abono-plan-card.html
@@ -0,0 +1,16 @@
+{{/* abono-plan-card — one plan at one interval: its name, the price
+ that is sold for it, and a button to buy.
+
+ A plan with nothing sold at Interval says so ("abono.plan.interval_unavailable")
+ and offers no button, rather than quietly selling another interval.
+ Prices that are no longer sold (legacy prices) are never shown.
+
+ Keys:
+ Offer abono.Offer, required
+ Interval string, optional — "month" or "year". Empty shows every
+ sold price, which is right for a plan with just one; with
+ several the button cannot choose between them, so name one.
+ Action string, required — where the buy form posts
+ Code bool, optional — a promotion code field in the buy form
+ L *ui.Localizer */}}
+{{define "abono-plan-card"}}{{$L := localizer $}}{{$iv := .Interval}}{{$found := false}}<div rst-box><h3>{{or .Offer.Name .Offer.Plan}}</h3>{{range .Offer.Prices}}{{if and .Sold (or (eq $iv "") (eq .Interval $iv))}}{{$found = true}}{{if .Currency}}<p>{{template "abono-price" dict "L" $L "Price" .}}</p>{{end}}{{end}}{{end}}{{if $found}}{{template "abono-subscribe-button" dict "L" $L "Action" .Action "Plan" .Offer.Plan "Interval" $iv "Code" (opt . "Code")}}{{else}}<p>{{T $L "abono.plan.interval_unavailable"}}</p>{{end}}</div>{{end}}
diff --git a/templates/abono-plan-picker.html b/templates/abono-plan-picker.html
new file mode 100644
index 0000000..2d56157
--- /dev/null
+++ b/templates/abono-plan-picker.html
@@ -0,0 +1,14 @@
+{{/* abono-plan-picker — the plans on sale, as cards grouped by interval:
+ the monthly plans, then the yearly ones. A plan appears in a group
+ only when it has a price sold at that interval. With only one
+ interval on sale there is one group and no switch label; with nothing
+ on sale the picker renders nothing.
+
+ The groups are all on the page, not toggled by script, so the picker
+ works without JavaScript and every price is readable at once.
+
+ Keys:
+ Offers []abono.Offer, required — from Service.Offers
+ Action string, required — where each card's buy form posts
+ L *ui.Localizer */}}
+{{define "abono-plan-picker"}}{{$L := localizer $}}{{$action := .Action}}{{$month := false}}{{$year := false}}{{range .Offers}}{{range .Prices}}{{if .Sold}}{{if eq .Interval "month"}}{{$month = true}}{{else if eq .Interval "year"}}{{$year = true}}{{end}}{{end}}{{end}}{{end}}{{if or $month $year}}<div{{if and $month $year}} role="group" aria-label="{{T $L "abono.price.switch_label"}}"{{end}}>{{range $iv := list "month" "year"}}{{if or (and (eq $iv "month") $month) (and (eq $iv "year") $year)}}<section>{{if and $month $year}}<p><strong>{{if eq $iv "month"}}{{T $L "abono.price.monthly"}}{{else}}{{T $L "abono.price.yearly"}}{{end}}</strong></p>{{end}}{{range $.Offers}}{{$has := false}}{{range .Prices}}{{if and .Sold (eq .Interval $iv)}}{{$has = true}}{{end}}{{end}}{{if $has}}{{template "abono-plan-card" dict "L" $L "Offer" . "Interval" $iv "Action" $action}}{{end}}{{end}}</section>{{end}}{{end}}</div>{{end}}{{end}}
diff --git a/templates/abono-price.html b/templates/abono-price.html
new file mode 100644
index 0000000..e8657a5
--- /dev/null
+++ b/templates/abono-price.html
@@ -0,0 +1,8 @@
+{{/* abono-price — one price with its interval: "€5.00 a month" in the
+ catalog's words. Used by the plan card; not for an app to call.
+
+ Keys:
+ Price abono.Price, required — a price Stripe has been read for
+ (a currency); the caller checks that
+ L *ui.Localizer */}}
+{{define "abono-price"}}{{$L := localizer $}}{{$m := money $L .Price.Amount .Price.Currency}}{{if eq .Price.Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Price.Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}}
diff --git a/templates/abono-status.html b/templates/abono-status.html
new file mode 100644
index 0000000..f9ffc3b
--- /dev/null
+++ b/templates/abono-status.html
@@ -0,0 +1,18 @@
+{{/* abono-status — a holding as one line: a status pill, never colour alone.
+
+ The words come from Describe: its message key is translated, and when
+ it has a date the key's {date} is filled with it. The tone is
+ Describe's, so the pill's colour and its words always agree.
+
+ The date is written year-month-day (2026-11-03), which reads the same
+ in every language. A date in the reader's own words needs a date
+ helper in core, which has none yet.
+
+ Keys:
+ Holding abono.Holding, required — what to describe
+ Now time.Time, required — the moment to describe it at; the
+ same Now the access check uses, so the line cannot say
+ "active" of access that has ended
+ L *ui.Localizer — the request's, found by {{localizer $}};
+ add it with withL when calling from another partial */}}
+{{define "abono-status"}}{{$L := localizer $}}{{$s := .Holding.Describe .Now}}{{$label := ""}}{{if $s.Date}}{{$label = Tf $L $s.Key "date" ($s.Date.Format "2006-01-02")}}{{else}}{{$label = T $L $s.Key}}{{end}}{{template "status-pill" dict "Tone" $s.Tone "Label" $label}}{{end}}
diff --git a/templates/abono-subscribe-button.html b/templates/abono-subscribe-button.html
new file mode 100644
index 0000000..187620e
--- /dev/null
+++ b/templates/abono-subscribe-button.html
@@ -0,0 +1,16 @@
+{{/* abono-subscribe-button — a POST form that starts Checkout. A POST
+ because the handler mints a Stripe session; csrf.Protect checks the
+ origin, so there is no token to carry.
+
+ The form sends plan, interval (when there is one) and code (when Code
+ is set), to Action.
+
+ Keys:
+ Action string, required — where the form posts
+ Plan string, required — the plan to buy
+ Interval string, optional — "month" or "year"; empty sends none,
+ and Checkout takes the plan's only sold price
+ Label string, optional — the button's words; "abono.button.subscribe" by default
+ Code bool, optional — a promotion code field above the button
+ L *ui.Localizer */}}
+{{define "abono-subscribe-button"}}{{$L := localizer $}}{{$id := printf "abono-code-%s-%s" .Plan (or (opt . "Interval") "any")}}<form rst-form-actions method="post" action="{{.Action}}"><input type="hidden" name="plan" value="{{.Plan}}">{{with opt . "Interval"}}<input type="hidden" name="interval" value="{{.}}">{{end}}{{if opt . "Code"}}<div rst-field><label rst-field-label for="{{$id}}">{{T $L "abono.plan.code_label"}}</label><input rst-input id="{{$id}}" name="code" type="text" autocomplete="off" autocapitalize="characters" spellcheck="false" aria-describedby="{{$id}}-hint"><small rst-field-hint id="{{$id}}-hint">{{T $L "abono.plan.code_hint"}}</small></div>{{end}}<button type="submit" rst-btn="primary">{{with opt . "Label"}}{{.}}{{else}}{{T $L "abono.button.subscribe"}}{{end}}</button></form>{{end}}
diff --git a/templates_test.go b/templates_test.go
new file mode 100644
index 0000000..69ea082
--- /dev/null
+++ b/templates_test.go
@@ -0,0 +1,520 @@
+package abono_test
+
+import (
+ "html/template"
+ "io/fs"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo"
+ _ "amadan.net/rastrillo/rastrillo/locales/en"
+ "amadan.net/rastrillo/rastrillo/ui"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// testCatalog is made-up English for these tests, one entry per message
+// key the partials use. It never ships: the approved English is the
+// subject of the copy review and lands in locales/en.toml, and these
+// strings only let a test see which key a partial reached for.
+var testCatalog = rastrillo.Catalog{
+ "abono.status.active": "T-ACTIVE",
+ "abono.status.renews": "T-RENEWS {date}",
+ "abono.status.cancelling": "T-CANCELLING {date}",
+ "abono.status.failing": "T-FAILING {date}",
+ "abono.status.ended": "T-ENDED",
+ "abono.status.comp": "T-COMP",
+ "abono.status.comp_until": "T-COMPUNTIL {date}",
+ "abono.price.per_month": "T-PERMONTH {amount}",
+ "abono.price.per_year": "T-PERYEAR {amount}",
+ "abono.price.monthly": "T-MONTHLY",
+ "abono.price.yearly": "T-YEARLY",
+ "abono.price.switch_label": "T-SWITCH",
+ "abono.plan.interval_unavailable": "T-NOINTERVAL",
+ "abono.plan.code_label": "T-CODELABEL",
+ "abono.plan.code_hint": "T-CODEHINT",
+ "abono.paywall.title": "T-PAYWALL-TITLE",
+ "abono.paywall.body": "T-PAYWALL-BODY",
+ "abono.paywall.signin_prompt": "T-SIGNIN-PROMPT",
+ "abono.paywall.signin": "T-SIGNIN",
+ "abono.button.subscribe": "T-SUBSCRIBE",
+ "abono.button.manage": "T-MANAGE",
+ "abono.button.see_plans": "T-SEEPLANS",
+}
+
+// partialNames are the partials abono ships, each with data that renders it.
+var partialNames = []string{
+ "abono-status", "abono-plan-card", "abono-plan-picker", "abono-paywall",
+ "abono-subscribe-button", "abono-billing-button", "abono-price",
+}
+
+// testLocalizer is a localizer over the framework's own English with
+// catalog merged in below it, the way an app merges abono.Catalog() into
+// the base catalog it hands rastrillo.NewLocales.
+func testLocalizer(t *testing.T, catalog rastrillo.Catalog) *ui.Localizer {
+ t.Helper()
+ base := rastrillo.BaseCatalog()
+ for k, v := range catalog {
+ base[k] = v
+ }
+ locs, err := rastrillo.NewLocales([]string{"en"}, "en", base, nil)
+ if err != nil {
+ t.Fatalf("NewLocales: %v", err)
+ }
+ return ui.NewLocalizer("en", "en", func(key string, args ...any) string {
+ return locs.Tf("en", key, args...)
+ })
+}
+
+// partials parses ui's partials and abono's, as an app does.
+func partials(t *testing.T) *template.Template {
+ t.Helper()
+ tmpl, err := template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html")
+ if err != nil {
+ t.Fatalf("parse ui: %v", err)
+ }
+ if tmpl, err = tmpl.ParseFS(abono.Templates(), "*.html"); err != nil {
+ t.Fatalf("parse abono: %v", err)
+ }
+ return tmpl
+}
+
+func render(t *testing.T, name string, data any) string {
+ t.Helper()
+ var b strings.Builder
+ if err := partials(t).ExecuteTemplate(&b, name, data); err != nil {
+ t.Fatalf("ExecuteTemplate(%q): %v", name, err)
+ }
+ return b.String()
+}
+
+var (
+ soon = time.Date(2026, 11, 3, 9, 30, 0, 0, time.UTC)
+ nowAt = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
+ yearly = abono.Price{ID: "price_y", Sold: true, Interval: "year", Amount: 5000, Currency: "EUR"}
+ monthly = abono.Price{ID: "price_m", Sold: true, Interval: "month", Amount: 500, Currency: "EUR"}
+ oldYr = abono.Price{ID: "price_old", Sold: false, Interval: "year", Amount: 4000, Currency: "EUR"}
+)
+
+func supporter() abono.Offer {
+ return abono.Offer{Plan: "supporter", Name: "Supporter", Prices: []abono.Price{monthly, yearly, oldYr}}
+}
+
+func yearlyOnly() abono.Offer {
+ return abono.Offer{Plan: "patron", Name: "Patron", Prices: []abono.Price{yearly}}
+}
+
+// sample is data that renders each partial with every optional key set.
+func sample(l *ui.Localizer) map[string]map[string]any {
+ holding := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Until: &soon, Grants: true}
+ return map[string]map[string]any{
+ "abono-status": {"L": l, "Holding": holding, "Now": nowAt},
+ "abono-plan-card": {"L": l, "Offer": supporter(), "Interval": "year", "Action": "/subscribe", "Code": true},
+ "abono-plan-picker": {"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"},
+ "abono-paywall": {"L": l, "Offers": []abono.Offer{supporter()}, "SignedIn": true, "SigninURL": "/signin", "Action": "/subscribe"},
+ "abono-subscribe-button": {"L": l, "Action": "/subscribe", "Plan": "supporter", "Interval": "year", "Label": "Join"},
+ "abono-billing-button": {"L": l, "Action": "/billing"},
+ "abono-price": {"L": l, "Price": monthly},
+ }
+}
+
+// whole renders, so a missing key or a stray placeholder shows.
+func checkClean(t *testing.T, name, got string) {
+ t.Helper()
+ for _, bad := range []string{
+ "http://", "https://", "//cdn", "<script", "<iframe", "<link ", "url(",
+ "xlink:href", "@import", "<style", " style=",
+ "<no value>", "abono.", "ZgotmplZ",
+ } {
+ if strings.Contains(got, bad) {
+ t.Errorf("partial %q renders %q:\n%s", name, bad, got)
+ }
+ }
+}
+
+func TestEveryPartialIsDefinedAndSelfContained(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ data := sample(l)
+ tmpl := partials(t)
+ for _, name := range partialNames {
+ if tmpl.Lookup(name) == nil {
+ t.Errorf("partial %q is not defined by abono.Templates()", name)
+ continue
+ }
+ d, ok := data[name]
+ if !ok {
+ t.Fatalf("no sample data for %q", name)
+ }
+ checkClean(t, name, render(t, name, d))
+ }
+}
+
+func TestTemplatesIsRootedAtThePartials(t *testing.T) {
+ entries, err := fs.ReadDir(abono.Templates(), ".")
+ if err != nil || len(entries) == 0 {
+ t.Fatalf("ReadDir: %v, %d entries", err, len(entries))
+ }
+ for _, e := range entries {
+ if !strings.HasSuffix(e.Name(), ".html") {
+ t.Errorf("unexpected entry %q at the root of Templates()", e.Name())
+ }
+ }
+}
+
+// holdingCase is a holding that Describe reads as one status key.
+type holdingCase struct {
+ name string
+ h abono.Holding
+ key string
+ tone string
+ text string // the test catalog's text
+ hasDate bool
+}
+
+func holdingCases() []holdingCase {
+ sub := func(status string, cancel bool) abono.Holding {
+ return abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: status, Until: &soon, CancelAtPeriodEnd: cancel, Grants: true}
+ }
+ started := nowAt.Add(-48 * time.Hour)
+ failing := sub("past_due", false)
+ failing.PeriodStart = &started
+ active := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Grants: true}
+ compUntil := abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Until: &soon, Grants: true}
+ return []holdingCase{
+ {"active", active, abono.StatusActive, "positive", "T-ACTIVE", false},
+ {"renews", sub("active", false), abono.StatusRenews, "positive", "T-RENEWS", true},
+ {"cancelling", sub("active", true), abono.StatusCancelling, "warning", "T-CANCELLING", true},
+ {"failing", failing, abono.StatusFailing, "warning", "T-FAILING", true},
+ {"ended", sub("canceled", false), abono.StatusEnded, "neutral", "T-ENDED", false},
+ {"comp", abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Grants: true}, abono.StatusComp, "positive", "T-COMP", false},
+ {"comp_until", compUntil, abono.StatusCompUntil, "positive", "T-COMPUNTIL", true},
+ }
+}
+
+// The date is written as the year-month-day it is, the one form that
+// reads the same in every language, until core has a date helper that
+// reads a time in the reader's locale.
+func TestStatusRendersEveryKey(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ seen := map[string]bool{}
+ for _, c := range holdingCases() {
+ t.Run(c.name, func(t *testing.T) {
+ s := abono.Describe(c.h, nowAt)
+ if s.Key != c.key || s.Tone != c.tone {
+ t.Fatalf("Describe = %q/%q, case wants %q/%q", s.Key, s.Tone, c.key, c.tone)
+ }
+ seen[s.Key] = true
+ got := render(t, "abono-status", map[string]any{"L": l, "Holding": c.h, "Now": nowAt})
+ checkClean(t, "abono-status", got)
+ if !strings.Contains(got, c.text) {
+ t.Errorf("status lacks the catalog text %q:\n%s", c.text, got)
+ }
+ wantDate := s.Date != nil
+ if wantDate != c.hasDate {
+ t.Fatalf("case says hasDate=%v, Describe gave %v", c.hasDate, s.Date)
+ }
+ if c.hasDate {
+ want := s.Date.Format("2006-01-02")
+ if !strings.Contains(got, want) {
+ t.Errorf("status lacks the date %q:\n%s", want, got)
+ }
+ if strings.Contains(got, "{date}") {
+ t.Errorf("status left the {date} placeholder:\n%s", got)
+ }
+ }
+ })
+ }
+ for _, key := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} {
+ if !seen[key] {
+ t.Errorf("no case renders %s", key)
+ }
+ }
+}
+
+var pillText = regexp.MustCompile(`^<span rst-status rst-tone="([a-z]+)">([^<]+)</span>$`)
+
+// A status is a word first. Every tone's pill carries visible text, so the
+// colour is never the only thing that says it.
+func TestStatusIsNeverColourAlone(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ tones := map[string]bool{}
+ for _, c := range holdingCases() {
+ got := render(t, "abono-status", map[string]any{"L": l, "Holding": c.h, "Now": nowAt})
+ m := pillText.FindStringSubmatch(strings.TrimSpace(got))
+ if m == nil {
+ t.Errorf("%s: not a status pill with text: %s", c.name, got)
+ continue
+ }
+ tones[m[1]] = true
+ if m[1] != c.tone {
+ t.Errorf("%s: tone %q, want %q", c.name, m[1], c.tone)
+ }
+ if strings.TrimSpace(m[2]) == "" {
+ t.Errorf("%s: pill has no label", c.name)
+ }
+ }
+ for _, tone := range []string{"positive", "warning", "neutral"} {
+ if !tones[tone] {
+ t.Errorf("no case covers tone %q", tone)
+ }
+ }
+}
+
+func TestPlanCard(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ card := func(o abono.Offer, interval string, code bool) string {
+ return render(t, "abono-plan-card", map[string]any{"L": l, "Offer": o, "Interval": interval, "Action": "/subscribe", "Code": code})
+ }
+
+ got := card(supporter(), "year", false)
+ for _, want := range []string{"Supporter", "T-PERYEAR", "€50.00", `action="/subscribe"`, `name="plan" value="supporter"`, `name="interval" value="year"`, "T-SUBSCRIBE"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("year card lacks %q:\n%s", want, got)
+ }
+ }
+ // The retired price is not for sale; the month price is another card's.
+ for _, bad := range []string{"€40.00", "€5.00", "T-PERMONTH", "T-CODELABEL", `name="code"`} {
+ if strings.Contains(got, bad) {
+ t.Errorf("year card shows %q:\n%s", bad, got)
+ }
+ }
+
+ if got := card(supporter(), "month", false); !strings.Contains(got, "T-PERMONTH") || strings.Contains(got, "T-PERYEAR") {
+ t.Errorf("month card: %s", got)
+ }
+
+ // An offer with nothing sold at the interval says so, and sells nothing.
+ got = card(yearlyOnly(), "month", true)
+ if !strings.Contains(got, "T-NOINTERVAL") {
+ t.Errorf("unavailable interval not said: %s", got)
+ }
+ if strings.Contains(got, "<form") || strings.Contains(got, "<button") {
+ t.Errorf("a card with nothing to sell offers a button: %s", got)
+ }
+
+ got = card(supporter(), "year", true)
+ for _, want := range []string{"T-CODELABEL", "T-CODEHINT", `name="code"`} {
+ if !strings.Contains(got, want) {
+ t.Errorf("card with a code lacks %q:\n%s", want, got)
+ }
+ }
+ // The label is tied to its input, and the ids are the plan's, so two
+ // cards on a page do not share one.
+ if !regexp.MustCompile(`<label[^>]* for="(abono-code-[^"]+)"`).MatchString(got) {
+ t.Errorf("code label is not for an abono-code input: %s", got)
+ }
+}
+
+func TestPlanPicker(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ got := render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"})
+ for _, want := range []string{"T-SWITCH", "T-MONTHLY", "T-YEARLY", "Supporter", "Patron"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("picker lacks %q:\n%s", want, got)
+ }
+ }
+ // Month comes first; Patron has no monthly price and is in the yearly group only.
+ if strings.Index(got, "T-MONTHLY") > strings.Index(got, "T-YEARLY") {
+ t.Errorf("monthly does not come before yearly: %s", got)
+ }
+ if n := strings.Count(got, "Patron"); n != 1 {
+ t.Errorf("Patron shown %d times, want once (yearly only): %s", n, got)
+ }
+ if n := strings.Count(got, "Supporter"); n != 2 {
+ t.Errorf("Supporter shown %d times, want twice (both intervals): %s", n, got)
+ }
+ if strings.Contains(got, "T-NOINTERVAL") {
+ t.Errorf("picker offers a plan at an interval it lacks: %s", got)
+ }
+
+ // With one interval on sale there is nothing to switch between.
+ got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{yearlyOnly()}, "Action": "/subscribe"})
+ if strings.Contains(got, "T-MONTHLY") || strings.Contains(got, "T-SWITCH") {
+ t.Errorf("picker with one interval shows a switch: %s", got)
+ }
+
+ // Nothing for sale renders nothing.
+ if got := strings.TrimSpace(render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{}, "Action": "/subscribe"})); got != "" {
+ t.Errorf("empty picker renders %q", got)
+ }
+}
+
+func TestPaywall(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ paywall := func(signedIn bool, extra map[string]any) string {
+ d := map[string]any{"L": l, "Offers": []abono.Offer{supporter()}, "SignedIn": signedIn, "SigninURL": "/signin?next=/post", "Action": "/subscribe"}
+ for k, v := range extra {
+ d[k] = v
+ }
+ return render(t, "abono-paywall", d)
+ }
+
+ out := paywall(false, nil)
+ for _, want := range []string{"T-PAYWALL-TITLE", "T-PAYWALL-BODY", "T-SIGNIN-PROMPT", `href="/signin?next=/post"`, "T-SIGNIN"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("signed-out paywall lacks %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "<form") {
+ t.Errorf("a reader who is not signed in is offered a purchase: %s", out)
+ }
+
+ in := paywall(true, nil)
+ for _, want := range []string{"T-PAYWALL-TITLE", "T-PAYWALL-BODY", "T-YEARLY", `action="/subscribe"`} {
+ if !strings.Contains(in, want) {
+ t.Errorf("signed-in paywall lacks %q:\n%s", want, in)
+ }
+ }
+ if strings.Contains(in, "T-SIGNIN") {
+ t.Errorf("signed-in paywall asks to sign in: %s", in)
+ }
+
+ link := paywall(true, map[string]any{"PlansURL": "/plans"})
+ if !strings.Contains(link, `href="/plans"`) || !strings.Contains(link, "T-SEEPLANS") || strings.Contains(link, "<form") {
+ t.Errorf("paywall with a PlansURL should link to the plans: %s", link)
+ }
+}
+
+func TestButtonsArePostForms(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ form := regexp.MustCompile(`^<form[^>]* method="post" action="([^"]+)"`)
+ for _, c := range []struct {
+ name string
+ data map[string]any
+ action string
+ want []string
+ }{
+ {"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter", "Interval": "year", "Label": "Join"}, "/subscribe",
+ []string{"Join", `name="plan" value="supporter"`, `name="interval" value="year"`}},
+ {"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter"}, "/subscribe",
+ []string{"T-SUBSCRIBE", `name="plan" value="supporter"`}},
+ {"abono-billing-button", map[string]any{"L": l, "Action": "/billing"}, "/billing", []string{"T-MANAGE"}},
+ } {
+ got := strings.TrimSpace(render(t, c.name, c.data))
+ m := form.FindStringSubmatch(got)
+ if m == nil || m[1] != c.action {
+ t.Errorf("%s: not a POST form to %s: %s", c.name, c.action, got)
+ }
+ if strings.Count(got, "<form") != 1 || !strings.Contains(got, `<button type="submit"`) || !strings.HasSuffix(got, "</form>") {
+ t.Errorf("%s: want one form ending in a submit button: %s", c.name, got)
+ }
+ for _, w := range c.want {
+ if !strings.Contains(got, w) {
+ t.Errorf("%s lacks %q: %s", c.name, w, got)
+ }
+ }
+ }
+ // Without an interval asked for, none is sent: Checkout takes the plan's only price.
+ if got := render(t, "abono-subscribe-button", map[string]any{"L": l, "Action": "/s", "Plan": "p"}); strings.Contains(got, `name="interval"`) {
+ t.Errorf("an empty interval is sent: %s", got)
+ }
+}
+
+// partialKeys are the abono.* keys the partials name, read from their
+// source.
+func partialKeys(t *testing.T) map[string]bool {
+ t.Helper()
+ keys := map[string]bool{}
+ re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`)
+ err := fs.WalkDir(abono.Templates(), ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ src, err := fs.ReadFile(abono.Templates(), p)
+ if err != nil {
+ return err
+ }
+ for _, m := range re.FindAllStringSubmatch(string(src), -1) {
+ keys[m[1]] = true
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return keys
+}
+
+// The keys of the agreed copy. A key outside this list is copy nobody
+// reviewed, so a partial that reaches for one fails here.
+var agreedKeys = []string{
+ "abono.status.active", "abono.status.renews", "abono.status.cancelling", "abono.status.failing",
+ "abono.status.ended", "abono.status.comp", "abono.status.comp_until",
+ "abono.price.per_month", "abono.price.per_year", "abono.price.monthly", "abono.price.yearly", "abono.price.switch_label",
+ "abono.plan.interval_unavailable", "abono.plan.code_label", "abono.plan.code_hint",
+ "abono.paywall.title", "abono.paywall.body", "abono.paywall.signin_prompt", "abono.paywall.signin",
+ "abono.button.subscribe", "abono.button.manage", "abono.button.see_plans",
+}
+
+func TestPartialsUseOnlyAgreedKeys(t *testing.T) {
+ agreed := map[string]bool{}
+ for _, k := range agreedKeys {
+ agreed[k] = true
+ if _, ok := testCatalog[k]; !ok {
+ t.Errorf("the test catalog lacks the agreed key %s", k)
+ }
+ }
+ for k := range testCatalog {
+ if !agreed[k] {
+ t.Errorf("the test catalog has %s, which is not an agreed key", k)
+ }
+ }
+ for k := range partialKeys(t) {
+ if !agreed[k] {
+ t.Errorf("a partial names %s, which is not an agreed key", k)
+ }
+ }
+}
+
+// Every key the partials use, or Describe returns, is in en.toml, and
+// every key in en.toml is used: here, or by cmd/abono's templates.
+func TestCatalogKeysAreUsedAndDefined(t *testing.T) {
+ cat := abono.Catalog()
+ if len(cat) == 0 {
+ t.Skip("Task 8b: approved copy pending")
+ }
+ used := partialKeys(t)
+ for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} {
+ used[k] = true
+ }
+ re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`)
+ _ = fs.WalkDir(osDir("cmd/abono"), ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(p, ".html") {
+ return nil
+ }
+ src, _ := fs.ReadFile(osDir("cmd/abono"), p)
+ for _, m := range re.FindAllStringSubmatch(string(src), -1) {
+ used[m[1]] = true
+ }
+ return nil
+ })
+ for k := range used {
+ if _, ok := cat[k]; !ok {
+ t.Errorf("%s is used but not in locales/en.toml", k)
+ }
+ }
+ for k := range cat {
+ if !used[k] {
+ t.Errorf("%s is in locales/en.toml but nothing uses it", k)
+ }
+ }
+}
+
+func TestNoEmDashesInCopy(t *testing.T) {
+ if len(abono.Catalog()) == 0 {
+ t.Skip("Task 8b: approved copy pending")
+ }
+ src, err := fs.ReadFile(osDir("locales"), "en.toml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.ContainsAny(string(src), "\u2014\u2013") {
+ t.Error("locales/en.toml has an em or en dash")
+ }
+}
+
+// osDir is dir under the package directory, as an fs.FS.
+func osDir(dir string) fs.FS { return os.DirFS(dir) }