16 KiB
16 KiB
Phase 4: CLI scaffolding, i18n and mail - Context
Gathered: 2026-09-18 Status: Ready for planning
## Phase BoundaryFramework repo (summercms.go) only. Three deliverables:
- Scaffolding (CLI-02):
summer make:plugin,make:model,make:migration,make:command,make:job,make:admin-controllergenerate stubs that compile and passgo vet, laid out in the WinterCMS directory shape and auto-wired through a generated per-plugin registry. - i18n (
phrasebook, I18N-01): namespaced keysvendor.plugin::group.keyloaded from per-plugin per-locale YAML,:nameparameter substitution, CLDR plurals for pl and en (go-i18n for category selection), locale fallback chain. - 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 throughhtml/templateplus 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.
Lang files and plurals (phrasebook)
- D-01: Lang files are
lang/<locale>/<group>.yamlinside the plugin module, embedded through theHasLangcapability; keys arevendor.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, pluszero/twowhere 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
:nameplaceholders 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→ parentpl→ 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.localeandapp.fallback_locale, framework defaultenfor both. Płytarium setsapp.locale: plin 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 atviews/mail/<name>.htminside the plugin, embedded throughHasMailTemplates, 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/templatefor 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), mirroringregisterMailLayouts(). 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 neutraldefaultlayout.
Scaffolding (make:*)
- D-10: Generated plugins follow the WinterCMS directory layout as Go subpackages, decided in
/gsd-exploreand 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.goandroutes.gosit 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.gomust be a mechanical move. - D-11:
models/is a leaf package: it never imports a sibling package of its own plugin. Casts move intomodels/(orlagoonwhen generic); lifecycle hooks that call services become GORM callbacks registered on the model fromclasses/orplugin.go— the same callback-registry mechanism Phase 5 uses for cross-plugin extension. Phase 4 ships the enforcement:summer build(or a frameworkgo testover the workspace) fails naming the plugin and the offending import when amodels/package imports a sibling. - D-12: Auto-wiring through a generated per-plugin
registry.gen.goin the root package: eachmake:*appends the new artifact to the generated slices (migrations, commands, jobs, admin controllers, models) that the scaffoldedplugin.go's capability methods return. Hand-writtenplugin.gois never rewritten. Existing hand-written plugins opt in by calling the same generated accessors (append(handWritten, generatedMigrations()...)); runningmake:*on a plugin without a registry creates it and prints the one-time line to add. The Phase 3fonoteka.goplugins are not touched in this phase. - D-13:
make:model <vendor.plugin> <Name>generates the model struct inmodels/(tablevendor_plugin_names, timestamps) and a create-table migration appended to the plugin's gormigrate set inupdates/;--no-migrationskips the migration.make:migrationappends a migration alone. - D-14:
make:admin-controllergenerates a stub satisfying a minimalpactadmin-controller interface defined in this phase (ID, model name, config directory) plusfields.yamlandcolumns.yamlplaceholders 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:jobgenerates a stub satisfying a framework job interface modeled on River's worker (aKind() stringon an args struct,Work(ctx, args) error) without importing River. Phase 11 adapts that interface onto River; jobs are unit-testable now by callingWorkdirectly. - D-16: Stub templates are embedded
text/templatefiles underinternal/build/stubs/*.tmpl, rendered and passed throughgo/format.make:plugin's existing Go string builders migrate to the same mechanism. A framework test generates every stub type into a temporary plugin insideexamples/helloand runsgo buildandgo veton 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 fromplugin.go(pluginIDFromGoininternal/build/scaffold.goalready does this).
Mail drivers and test send
- D-18: Three drivers ship behind the
postcarddriver 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 amail.*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
memorydriver 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.Mailerservice published on thebackpackcontainer:Send(ctx, postcard.Message{Template, To, Cc, Bcc, ReplyTo, Vars}) error. This is the port shape forMail::send('vendor.plugin::mail.x', $vars, fn): the PHP closure's recipient/subject calls become struct fields. No per-template Go types. - D-21:
Sendreturns 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, failsBootwith the plugin ID and the name, consistent with Phase 1 D-10.
Claude's Discretion
- Exact
phrasebookAPI 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; howsummer buildlocates each plugin'smodels/package for the leaf check. - What
make:pluginscaffolds beyond Phase 1's minimum now that the directory layout is fixed (emptylang/en/lang.yaml,views/mail/,updates/package with an empty set are reasonable). - Whether
make:commandstubs land inconsole/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/httpcalls; 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, themodels-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,:namecase 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;HasMigrationscapability) whichmake:migrationappends to
Research
.planning/research/ARCHITECTURE.md— Pattern 1 capability interfaces (HasLang,HasMailTemplatesasfs.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,registerMailTemplatespairs.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 withcss/brandCss)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.phplines 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.tmplfiles (D-16)cmd/summer/main.go:make:plugin,dev,plugin:addasbonfire.Commandvalues — newmake:*commands register the same waypact/capabilities.go:HasLangandHasMailTemplatesare reserved names in the comment block; both were sketched asfs.FSreturners in ARCHITECTURE.mdexamples/hello(pluginsbase,greeter,optional): permanent testbed; the stub-compile test (D-16) and a lang + mail smoke can live herebonfireoutput widgets and non-TTY degradation formake:*feedbackcompasstyped section loading forapp.locale/mail.*
Established Patterns
- Capability discovery by type assertion at a fixed kernel point (
HasConfigbefore Register,HasMigrationsin lagoon migrate,HasRoutesin surf assemble) —HasLangandHasMailTemplatesget 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.Commandwith injectedOutput
Integration Points
pact: new minimal interfaces for admin controllers (D-14) and jobs (D-15), plusHasLang/HasMailTemplatesbackpack:postcard.Mailerand the phrasebook translator published as servicessummer build/internal/build: themodels-leaf check (D-11) andregistry.gen.gogeneration (D-12)surflocale middleware (Phase 3 D-12 readsAccept-Languageinto 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-ennames, 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 }}.
- Restructuring the flat Phase 3
fonoteka.goplugins into the D-10 layout — Phase 5, when they are widened anyway. - Validating the
models-leaf rule on keios.eu / jz / pxpx plugins — todoverify-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:testCLI 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