diff --git a/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md new file mode 100644 index 0000000..96fc3b9 --- /dev/null +++ b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md @@ -0,0 +1,141 @@ +# 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* diff --git a/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-DISCUSSION-LOG.md b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-DISCUSSION-LOG.md new file mode 100644 index 0000000..c5ded10 --- /dev/null +++ b/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-DISCUSSION-LOG.md @@ -0,0 +1,207 @@ +# Phase 4: CLI scaffolding, i18n and mail - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-09-17 / 2026-09-18 +**Phase:** 04-cli-scaffolding-i18n-and-mail +**Areas discussed:** Lang file & plural syntax, Mail template format, Scaffold stub behavior, Mail drivers & test send + +--- + +## Lang file & plural syntax + +### Plural message form + +| Option | Description | Selected | +|--------|-------------|----------| +| CLDR category map | `{one, few, many, other}` YAML map; 1:1 onto go-i18n; covers Polish's 4th category | | +| Laravel pipe strings | `":count wpis\|:count wpisy\|:count wpisów"` plus `{0}`/`[2,*]` ranges, per summer-phrasebook | | +| Both accepted | Map = CLDR categories; string with `\|` = Laravel-style | ✓ | + +**Notes:** Fonoteka PHP lang files contain no pipe plurals; the pipe form is for line-by-line ports of other WinterCMS plugins. + +### Parameter placeholders + +| Option | Description | Selected | +|--------|-------------|----------| +| `:name` | WinterCMS/Laravel style with `:Name`/`:NAME` variants; phrasebook substitutes, go-i18n only selects plural category | ✓ | +| `{{.Name}}` go-i18n native | Every ported string rewritten | | +| You decide | | | + +### Missing key + +| Option | Description | Selected | +|--------|-------------|----------| +| Fallback chain, then raw key | requested → parent → fallback → key string; logged once in dev/test | ✓ | +| Fallback chain, then error | `(string, error)` or panic in dev | | +| You decide | | | + +### Default locale + +| Option | Description | Selected | +|--------|-------------|----------| +| Config-driven, en default | `app.locale` / `app.fallback_locale`, framework default `en` | ✓ | +| Config-driven, pl default | | | +| You decide | | | + +--- + +## Mail template format + +### File shape + +| Option | Description | Selected | +|--------|-------------|----------| +| YAML front matter + body | `---` header parsed with goccy/go-yaml | | +| Keep WinterCMS INI + `==` | Exactly the PHP file shape; small hand-written header parser | ✓ | +| Go registration + body-only files | Subject/layout declared in Go | | + +### Body format + +| Option | Description | Selected | +|--------|-------------|----------| +| Markdown, add a renderer | html/template for vars, then Markdown → HTML; raw Markdown is the text part; one new dependency (goldmark expected) | ✓ | +| Plain HTML only | Zero new deps; six templates hand-converted; no text part | | +| HTML now, Markdown later | Body-renderer seam, decide at Phase 12/13 | | + +### Per-locale suffix + +| Option | Description | Selected | +|--------|-------------|----------| +| Suffix lookup with fallback | postcard tries `name-` then `name` | | +| Caller picks the full name | Exactly like PHP; no locale logic in postcard | ✓ | +| You decide | | | + +### Layouts + +| Option | Description | Selected | +|--------|-------------|----------| +| Registered by short name, HTML + text | Mirrors `registerMailLayouts` and the three-section PHP layout; framework ships `default` | ✓ | +| HTML wrapper only | Drops the text section | | +| You decide | | | + +--- + +## Scaffold stub behavior + +### Wiring + +| Option | Description | Selected | +|--------|-------------|----------| +| Auto-wire via generated registry file | Per-plugin `registry.gen.go`; plugin.go never rewritten | ✓ | +| Edit plugin.go in place | go/ast insertion | | +| Write file, print the line to add | | | + +### make:model output + +| Option | Description | Selected | +|--------|-------------|----------| +| Model + migration, flag to skip | `--no-migration` | ✓ | +| Model only | | | +| Model + migration + fields.yaml/columns.yaml | No consumer until Phase 9 | | + +### make:admin-controller before Phase 9 + +| Option | Description | Selected | +|--------|-------------|----------| +| Minimal interface + placeholder YAML | Tiny pact interface, WinterCMS-shaped YAML placeholders | ✓ | +| Go stub only, no YAML | | | +| You decide | | | + +### Stub templates + +| Option | Description | Selected | +|--------|-------------|----------| +| Embedded text/template .tmpl files | `internal/build/stubs`, go/format, compile test in examples/hello | ✓ | +| Keep Go string builders | | | +| You decide | | | + +### make:job before River + +| Option | Description | Selected | +|--------|-------------|----------| +| Framework job interface, River-shaped | `Kind()` on args, `Work(ctx, args) error`; no River import | ✓ | +| Import River now | River enters go.mod six phases early | | +| You decide | | | + +### File layout inside a plugin + +| Option | Description | Selected | +|--------|-------------|----------| +| Flat, one package, name suffixes | fonoteka.go Phase 3 style; no intra-plugin imports | | +| WinterCMS-style subpackages | models/, controllers/, console/, jobs/, updates/ as packages | ✓ (via /gsd-explore) | +| Flat Go, WinterCMS dirs for non-Go assets | | | + +**User's choice:** Deferred to `/gsd-explore` ("This is a difficult one"), then decided: WinterCMS-style subpackages because agents converting golem15/jz/pxpx plugins lean on the PHP **directory**, not the class graph. Added rule: `models/` is a leaf package (user agreed "first option seems the best"), casts move in, service-calling hooks become callbacks from `classes/`/`plugin.go`, enforced by `summer build`. Evidence: Fonoteka has 40 `classes→models` edges and exactly 1 `models→classes` edge (a cast and a hook in `Album.php`). Recorded in `.planning/notes/plugin-layout-winter-directories.md`. + +### Coexistence with hand-written plugins + +| Option | Description | Selected | +|--------|-------------|----------| +| Opt-in, merged by plugin.go | Hand-written plugins call generated accessors; fonoteka.go untouched | ✓ | +| Migrate existing plugins in this phase | Touches fonoteka.go, out of phase scope | | +| You decide | | | + +### Argument shape + +| Option | Description | Selected | +|--------|-------------|----------| +| `make:model ` | Plugin ID inferred from plugin.go when inside a plugin dir | ✓ | +| Always explicit, no inference | | | +| You decide | | | + +--- + +## Mail drivers & test send + +### Drivers + +| Option | Description | Selected | +|--------|-------------|----------| +| smtp + log + memory | go-mail, logger, in-memory slice | ✓ | +| smtp + memory only | | | +| smtp only | | | + +### Test send + +| Option | Description | Selected | +|--------|-------------|----------| +| Unit test + SMTP integration test | memory driver unit test plus Mailpit container test gated like testcontainers Postgres | ✓ | +| Unit test with memory driver only | | | +| `summer mail:test` command | Manual verification | | + +### Send API + +| Option | Description | Selected | +|--------|-------------|----------| +| Mailer service on the container | `postcard.Mailer.Send(ctx, Message{Template, To, Vars})` | ✓ | +| Typed message per template | | | +| You decide | | | + +### Failures + +| Option | Description | Selected | +|--------|-------------|----------| +| Error returned, unknown template fails at boot | Consistent with Phase 1 D-10 | ✓ | +| Error returned, lazy template check | | | +| You decide | | | + +--- + +## Claude's Discretion + +- phrasebook API surface, aliases/overrides, request-context seam for Phase 7 +- Markdown renderer pick (research) and escaping of substituted values +- registry.gen.go naming and accessor names; how the models-leaf check locates packages +- What make:plugin scaffolds beyond Phase 1's minimum in the new layout +- make:command naming under the plugin's colon prefix +- Mailpit image and API access +- Plan count and split + +## Deferred Ideas + +- Restructure Phase 3 fonoteka.go plugins into the directory layout — Phase 5 +- Validate the models-leaf rule on keios.eu / jz / pxpx plugins — todo `verify-models-leaf-rule` +- `summer mail:test` production smoke command +- Attachments on `postcard.Message`