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.
15 files changed,
+829
−0
catalog.go+115 −0catalog_test.go+49 −0describe.go+4 −0go.mod+1 −0go.sum+2 −0locales/en.toml+1 −0templates.go+40 −0templates/abono-billing-button.html+8 −0templates/abono-paywall.html+17 −0templates/abono-plan-card.html+16 −0templates/abono-plan-picker.html+14 −0templates/abono-price.html+8 −0templates/abono-status.html+18 −0templates/abono-subscribe-button.html+16 −0templates_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
Intent
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) } |