Files
summercms/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md
2026-09-18 02:14:25 +02:00

16 KiB
Raw Blame History

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/<locale>/<group>.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/<name>.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 <vendor.plugin> <Name> 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/<name>/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:<kind> <vendor.plugin> <Name>. 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:<name>), 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_refs>

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

</canonical_refs>

<code_context>

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

</code_context>

## 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