# Phase 4: CLI scaffolding, i18n and mail - Context **Gathered:** 2026-09-18 **Status:** Ready for planning ## Phase Boundary Framework repo (`summercms.go`) only. Three deliverables: 1. **Scaffolding** (CLI-02): `summer make:plugin`, `make:model`, `make:migration`, `make:command`, `make:job`, `make:admin-controller` generate stubs that compile and pass `go vet`, laid out in the WinterCMS directory shape and auto-wired through a generated per-plugin registry. 2. **i18n** (`phrasebook`, I18N-01): namespaced keys `vendor.plugin::group.key` loaded from per-plugin per-locale YAML, `:name` parameter substitution, CLDR plurals for pl and en (go-i18n for category selection), locale fallback chain. 3. **Mail** (`postcard`, I18N-03): plugins register mail templates and layouts by dotted name with the WinterCMS per-locale suffix convention; templates are WinterCMS-shaped files (INI header, `==`, Markdown body) rendered through `html/template` plus a Markdown renderer, sent through a driver interface (smtp via go-mail, log, memory). Not in this phase: per-request locale resolution (Phase 7, I18N-02), translated validation messages (`lifeguard`), queued mail and River (Phase 11), the admin schema pipeline (Phase 9), restructuring the Phase 3 `fonoteka.go` plugins (Phase 5), any writes to `fonoteka.go`. ## Implementation Decisions ### Lang files and plurals (phrasebook) - **D-01:** Lang files are `lang//.yaml` inside the plugin module, embedded through the `HasLang` capability; keys are `vendor.plugin::group.dot.path`, group = filename. Nested YAML maps flatten to dot paths, as the summer-phrasebook design did for HOCON. - **D-02:** Two plural syntaxes are both accepted. A YAML **map** under a key is a CLDR category map (`one`, `few`, `many`, `other`, plus `zero`/`two` where a locale has them) and is selected through go-i18n's plural rules. A **string containing `|`** is parsed Laravel-style: pipe-separated forms in CLDR category order, with optional explicit `{0}` / `[2,*]` conditions, as in the summer-phrasebook design. Fonoteka's PHP lang files contain no pipe plurals, so the map form is the primary target; the pipe form exists for line-by-line ports of other WinterCMS plugins. - **D-03:** Parameters use `:name` placeholders with the `:Name` (ucfirst) and `:NAME` (upper) case variants. phrasebook performs substitution itself; go-i18n is used only for CLDR plural category selection, not for message templating. PHP lang values copy over unchanged. - **D-04:** Missing keys walk the fallback chain (requested `pl-PL` → parent `pl` → configured fallback) and then return the raw key string, as WinterCMS does. In non-production environments a missing key is logged once per key. - **D-05:** Default and fallback locale come from config: `app.locale` and `app.fallback_locale`, framework default `en` for both. Płytarium sets `app.locale: pl` in its own config; the framework ships no Polish default. ### Mail template format (postcard) - **D-06:** Template files keep the WinterCMS shape exactly: an INI-style header (`subject = "..."`, `description = "..."`, `layout = "..."`), a `==` separator line, then the body. Files live at `views/mail/.htm` inside the plugin, embedded through `HasMailTemplates`, and are registered by dotted name (`golem15.fonoteka::mail.collection_invitation`). The subject line is itself a template so `{{ .CollectionName }}` works there. postcard ships a small header parser; no INI library. - **D-07:** Bodies are Markdown. Rendering order: `html/template` for variable substitution on the Markdown source, then Markdown → HTML for the HTML part; the substituted Markdown source is the plain-text part, as WinterCMS does. This adds one dependency to name in research (goldmark is the expected pick; research confirms version and HTML-escaping behavior of substituted values). - **D-08:** Per-locale templates are the PHP convention with no framework magic: the unsuffixed name is the app-default-locale version, `-en` (etc.) suffixed names are explicit siblings, and **the caller picks the full name**. postcard does no locale lookup or fallback at send time. - **D-09:** Layouts are registered by short name (`plytarium` → `golem15.fonoteka::mail.layouts.plytarium`), mirroring `registerMailLayouts()`. A layout file has the same INI + `==` shape with a text wrapper section and an HTML wrapper section, each rendering `{{ .Content }}`; shared variables (`css`, brand css) come from config or the registering plugin. The framework ships one neutral `default` layout. ### Scaffolding (make:*) - **D-10:** Generated plugins follow the WinterCMS directory layout as Go subpackages, decided in `/gsd-explore` and recorded in `.planning/notes/plugin-layout-winter-directories.md`: `models/`, `classes/`, `controllers/`, `console/`, `jobs/`, `middleware/`, `updates/` are packages; `lang/`, `views/mail/`, `config/` are embedded asset directories; `plugin.go` and `routes.go` sit in the root package and import the leaves. Chosen because agents converting golem15, jz and pxpx plugins lean on the PHP directory: `models/Album.php` → `models/album.go` must be a mechanical move. - **D-11:** `models/` is a leaf package: it never imports a sibling package of its own plugin. Casts move into `models/` (or `lagoon` when generic); lifecycle hooks that call services become GORM callbacks registered on the model from `classes/` or `plugin.go` — the same callback-registry mechanism Phase 5 uses for cross-plugin extension. Phase 4 ships the enforcement: `summer build` (or a framework `go test` over the workspace) fails naming the plugin and the offending import when a `models/` package imports a sibling. - **D-12:** Auto-wiring through a generated per-plugin `registry.gen.go` in the root package: each `make:*` appends the new artifact to the generated slices (migrations, commands, jobs, admin controllers, models) that the scaffolded `plugin.go`'s capability methods return. Hand-written `plugin.go` is never rewritten. Existing hand-written plugins opt in by calling the same generated accessors (`append(handWritten, generatedMigrations()...)`); running `make:*` on a plugin without a registry creates it and prints the one-time line to add. The Phase 3 `fonoteka.go` plugins are not touched in this phase. - **D-13:** `make:model ` generates the model struct in `models/` (table `vendor_plugin_names`, timestamps) **and** a create-table migration appended to the plugin's gormigrate set in `updates/`; `--no-migration` skips the migration. `make:migration` appends a migration alone. - **D-14:** `make:admin-controller` generates a stub satisfying a minimal `pact` admin-controller interface defined in this phase (ID, model name, config directory) plus `fields.yaml` and `columns.yaml` placeholders in the WinterCMS shape Fonoteka already uses (`controllers//fields.yaml`). Phase 9 grows the interface and the template; the stub only has to compile and vet now. - **D-15:** `make:job` generates a stub satisfying a framework job interface modeled on River's worker (a `Kind() string` on an args struct, `Work(ctx, args) error`) **without importing River**. Phase 11 adapts that interface onto River; jobs are unit-testable now by calling `Work` directly. - **D-16:** Stub templates are embedded `text/template` files under `internal/build/stubs/*.tmpl`, rendered and passed through `go/format`. `make:plugin`'s existing Go string builders migrate to the same mechanism. A framework test generates every stub type into a temporary plugin inside `examples/hello` and runs `go build` and `go vet` on it: that test is success criterion 1. - **D-17:** Argument shape is `make: `. When run inside a plugin directory the plugin ID may be omitted and is read from `plugin.go` (`pluginIDFromGo` in `internal/build/scaffold.go` already does this). ### Mail drivers and test send - **D-18:** Three drivers ship behind the `postcard` driver interface: `smtp` (go-mail), `log` (writes the rendered headers and text part to the app logger, for dev), `memory` (keeps sent messages in a slice for tests). Driver and connection settings come from a `mail.*` config section (`config/mail.yaml`, `SUMMER_MAIL__…` overrides per Phase 1 D-08). - **D-19:** Success criterion 3 is proven twice: a unit test renders a registered template + layout through the `memory` driver and asserts subject, HTML part and text part; an integration test, gated the same way as the testcontainers Postgres tests (fails when Docker is unavailable, skipped under `-short`), starts a Mailpit container and asserts through its API that the message arrived over SMTP via go-mail. - **D-20:** Sending is a `postcard.Mailer` service published on the `backpack` container: `Send(ctx, postcard.Message{Template, To, Cc, Bcc, ReplyTo, Vars}) error`. This is the port shape for `Mail::send('vendor.plugin::mail.x', $vars, fn)`: the PHP closure's recipient/subject calls become struct fields. No per-template Go types. - **D-21:** `Send` returns the driver error to the caller; no swallowing, no retries (retries belong to Phase 11's queue). Registering a template whose file is missing, or a template naming an unregistered layout, fails `Boot` with the plugin ID and the name, consistent with Phase 1 D-10. ### Claude's Discretion - Exact `phrasebook` API surface (`Get`, `Choice`, locale on context via an unexported key with exported accessors per ARCHITECTURE.md), namespace aliases and runtime overrides from the phrasebook design if cheap; the request-context seam for Phase 7. - Which Markdown renderer (research names it; goldmark expected) and how substituted values are escaped before Markdown rendering. - Registry file name and the exact accessor names in `registry.gen.go`; how `summer build` locates each plugin's `models/` package for the leaf check. - What `make:plugin` scaffolds beyond Phase 1's minimum now that the directory layout is fixed (empty `lang/en/lang.yaml`, `views/mail/`, `updates/` package with an empty set are reasonable). - Whether `make:command` stubs land in `console/` with a colon-style name derived from the plugin ID (`fonoteka:`), following Phase 1 D-15. - Mailpit image and API client (a few `net/http` calls; no SDK). - Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" rules. ## Canonical References **Downstream agents MUST read these before planning or implementing.** ### Plugin layout (binding for all make:* output) - `.planning/notes/plugin-layout-winter-directories.md` — the directory-as-subpackage layout, the `models`-is-a-leaf rule, the Fonoteka dependency evidence and the accepted trade-off - `.planning/todos/pending/verify-models-leaf-rule.md` — follow-up validation on other plugins (not Phase 4 work) ### Design carry-overs - `../modules/summer-phrasebook/README.md` — key format, `:name` case variants, pipe-string plural syntax with `{n}`/`[a,b]` conditions, CLDR form ordering, fallback chain, aliases and overrides - `.planning/phases/01-framework-kernel-foundation/01-CONTEXT.md` — D-03 (examples/hello as testbed and template source), D-06–D-09 (config layout, `SUMMER_` env mapping), D-10 (fail boot naming plugin + missing thing), D-14–D-17 (bonfire output, colon command names, `bonfire.Command`) - `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md` — D-16–D-19 (squashed, never-edited per-plugin gormigrate sets; `HasMigrations` capability) which `make:migration` appends to ### Research - `.planning/research/ARCHITECTURE.md` — Pattern 1 capability interfaces (`HasLang`, `HasMailTemplates` as `fs.FS`), package table (`phrasebook`, `postcard`), Anti-Pattern on context keys - `.planning/research/STACK.md` — go-i18n/v2 v2.6.1 (CLDR v48), goccy/go-yaml, testcontainers-go; go-mail is named in ARCHITECTURE.md's package table - `.planning/research/FEATURES.md` §Console / i18n — mail-template-per-locale-by-suffix convention, `registerMailTemplates` pairs - `.planning/research/PITFALLS.md` §dev loop (line ~309) — scaffolding quality is what makes the compiled-plugin loop viable for agents ### PHP reference (file shapes to mirror, read-only) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/collection_invitation-en.htm` — INI header + `==` + Markdown body - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/views/mail/layouts/plytarium.htm` — three-section layout (name header, text wrapper, HTML wrapper with `css`/`brandCss`) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php` lines 333–351 — `registerMailTemplates()` / `registerMailLayouts()` - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/{pl,en}/lang.php` — key structure to mirror in YAML (no plural pipes present) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/` directory tree — the layout D-10 mirrors ## Existing Code Insights ### Reusable Assets - `internal/build/scaffold.go`: `MakePlugin`, `AddPlugin`, `pluginIDFromGo`, go.mod/go.work editing, toolchain pinning — `make:*` commands build on these; the Go-string source builders migrate to embedded `.tmpl` files (D-16) - `cmd/summer/main.go`: `make:plugin`, `dev`, `plugin:add` as `bonfire.Command` values — new `make:*` commands register the same way - `pact/capabilities.go`: `HasLang` and `HasMailTemplates` are reserved names in the comment block; both were sketched as `fs.FS` returners in ARCHITECTURE.md - `examples/hello` (plugins `base`, `greeter`, `optional`): permanent testbed; the stub-compile test (D-16) and a lang + mail smoke can live here - `bonfire` output widgets and non-TTY degradation for `make:*` feedback - `compass` typed section loading for `app.locale` / `mail.*` ### Established Patterns - Capability discovery by type assertion at a fixed kernel point (`HasConfig` before Register, `HasMigrations` in lagoon migrate, `HasRoutes` in surf assemble) — `HasLang` and `HasMailTemplates` get their own assertion points in phrasebook/postcard boot - Fail-boot-with-names for missing dependencies, middleware and now templates/layouts - Per-plugin ordered slices (migrations) composed at boot in `Requires()` order — lang namespaces and mail template registries compose the same way - testcontainers gating: fail when Docker is unavailable, skip under `-short` (Phase 2/3) — reused for Mailpit - Colon-style command names; `bonfire.Command` with injected `Output` ### Integration Points - `pact`: new minimal interfaces for admin controllers (D-14) and jobs (D-15), plus `HasLang` / `HasMailTemplates` - `backpack`: `postcard.Mailer` and the phrasebook translator published as services - `summer build` / `internal/build`: the `models`-leaf check (D-11) and `registry.gen.go` generation (D-12) - `surf` locale middleware (Phase 3 D-12 reads `Accept-Language` into context): phrasebook exposes the context accessor it will use; Phase 7 fills in user-preferred locale ## Specific Ideas - The user consistently chose the WinterCMS-literal option over the "Go-native" one when the two diverged (INI + `==` mail files, caller-picked `-en` names, both plural syntaxes, directory-as-subpackage layout). Downstream: when a WinterCMS shape and a Go-idiomatic shape compete and neither is decided here, prefer the WinterCMS shape unless it fights the compiler. - The conversion audience is agents porting many plugins (golem15, jz, pxpx projects), so rules must be mechanical and tool-enforced (D-11's build check), not documented conventions. - Fonoteka mail bodies use `**bold**` and `[link](url)` Markdown with Twig variables; the Go port keeps them recognisably the same file with `{{ .Var }}` instead of `{{ var }}`. ## Deferred Ideas - Restructuring the flat Phase 3 `fonoteka.go` plugins into the D-10 layout — Phase 5, when they are widened anyway. - Validating the `models`-leaf rule on keios.eu / jz / pxpx plugins — todo `verify-models-leaf-rule`, before Phase 5's bulk port. - Per-request locale from `preferred_locale` — Phase 7 (I18N-02). - Queued sends, retries, River adapter for the Phase 4 job interface — Phase 11. - `summer mail:test` CLI command for production smoke sends — not needed for the criterion; capture if a later phase wants it. - Attachments on `postcard.Message` — not required by any Fonoteka template; add when a port needs it. --- *Phase: 04-cli-scaffolding-i18n-and-mail* *Context gathered: 2026-09-18*