Files
summercms/.planning/phases/04-cli-scaffolding-i18n-and-mail/04-PATTERNS.md
2026-09-18 12:43:41 +02:00

199 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 4: CLI scaffolding, i18n and mail — Pattern Map
**Mapped:** 2026-09-18
**Scope:** framework repository only; paths with `<plugin>` describe generated output, not edits to `fonoteka.go`.
**Files classified:** 29 file or file-family targets
**Analogs found:** 24/29 (including partial matches)
## File Classification
| New/Modified File or Family | Role | Data Flow | Closest Existing Analog | Match |
|---|---|---|---|---|
| `cmd/summer/main.go` | CLI controller | request-response | `cmd/summer/main.go:26-82` | exact |
| `internal/build/scaffold.go` | service | file I/O | `internal/build/scaffold.go:24-81,219-250` | exact |
| `internal/build/registry.go` (or equivalent) | utility | transform/file I/O | `internal/build/build.go:126-165` | role |
| `internal/build/leaf.go` (or equivalent) | utility | file I/O | `internal/build/scaffold.go:219-250` | partial |
| `internal/build/stubs/*.tmpl` | template/config | transform | no embedded Go templates exist | none |
| `internal/build/build.go` | service | batch/file I/O | `internal/build/build.go:18-75` | exact |
| `pact/capabilities.go` | contracts | request-response | `pact/capabilities.go:11-53` | exact |
| `phrasebook/loader.go` | service | file I/O/transform | `compass/config.go:288-332` | role |
| `phrasebook/translator.go` | service | request-response | `compass/config.go:112-164` | partial |
| `phrasebook/plural.go` | utility | transform | no CLDR or pipe-form parser exists | none |
| `postcard/mailer.go` | service | request-response | `lagoon/connection.go:82-111` | partial |
| `postcard/templates.go` | service | file I/O/transform | `compass/config.go:288-332` | partial |
| `postcard/drivers.go` and `postcard/log.go` | service | request-response | `lagoon/connection.go:26-48` | partial |
| `postcard/smtp.go` | service | request-response | no SMTP sender exists | none |
| `postcard/memory.go` | service | event-driven | `backpack/services.go:9-60` | partial |
| `examples/hello/plugins/base/plugin.go` | provider | file I/O | same file, lines 3-30 | exact |
| `examples/hello/plugins/base/lang/{en,pl}/*.yaml` | asset/config | file I/O | `examples/hello/plugins/base/config/config.yaml` | partial |
| `examples/hello/plugins/base/views/mail/*.htm` | asset/config | file I/O/transform | no mail asset exists | none |
| `examples/hello/config/app.yaml`, `config/mail.yaml` | config | file I/O | `examples/hello/config/app.yaml:1-2` | role |
| `go.mod`, `go.sum` | config | dependency resolution | `go.mod:6-23` | exact |
| `<plugin>/plugin.go` and `<plugin>/registry.gen.go` | provider/generated config | batch | `examples/hello/plugins/base/plugin.go:17-30`; `internal/build/build.go:126-143` | role |
| `<plugin>/models/<name>.go` | model | CRUD | `pact/capabilities.go:51-54` only | partial |
| `<plugin>/updates/<migration>.go`, `updates/migrations.go` | migration | CRUD | `lagoon/migrations_test.go:147-181` | role |
| `<plugin>/console/<name>.go` | CLI controller | request-response | `examples/hello/plugins/greeter/plugin.go:78-119` | role |
| `<plugin>/jobs/<name>.go`, `<plugin>/controllers/<name>.go` and YAML | job/controller | event-driven/request-response | no job or admin controller implementation exists | none |
| `internal/build/build_test.go`, `cmd/summer/main_test.go` | test | batch | same files, `build_test.go:143-242,340-485`; `main_test.go:17-40` | exact |
| `phrasebook/*_test.go`, `postcard/*_test.go` | test | request-response | `pact/capabilities_test.go:87-100`; `backpack/services_test.go` | role |
| `postcard/mailpit_test.go` | integration test | request-response | `lagoon/postgres_test.go:25-103` | role |
| `examples/hello/hello_test.go` | integration test | request-response | same file, lines 59-89, 91-108 | exact |
The file names within new packages are planning suggestions; D-01–D-21 fix behavior and generated directory shapes, not implementation file splits. `go.sum` changes when dependencies are added. Generated assets under `<plugin>` are products of `make:*`, with a temporary `examples/hello` copy as the compile/vet fixture.
## Pattern Assignments
### CLI command registration — `cmd/summer/main.go`
Copy `cmd/summer/main.go:26-40` to register all five new commands; copy `:56-82` for argument lookup, working-directory resolution, delegation to `internal/build`, and output. A typical existing command is:
```go
func makePluginCommand() bonfire.Command {
return bonfire.Command{
Name: "make:plugin",
Args: []bonfire.Arg{{Name: "id", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
id, ok := in.Argument("id")
if !ok || id == "" { return fmt.Errorf("make:plugin requires a vendor.plugin id") }
dir, err := os.Getwd()
if err != nil { return err }
created, err := build.MakePlugin(ctx, dir, id)
if err != nil { return err }
out.Printf("created %s\n", created)
return nil
},
}
}
```
For `make:<kind>`, expose both `<vendor.plugin> <Name>` and the in-plugin omitted-ID form from D-17. `bonfire.Arg`/`Flag` definitions are the CLI validation surface; `build.ValidatePluginID` in `internal/build/manifest.go:110-122` supplies plugin ID validation. Add every new command name to `cmd/summer/main_test.go:17-40`.
### Scaffold, registry and leaf check — `internal/build/`
`internal/build/scaffold.go:24-81` is the existing create flow: validate ID, locate app root, check `underRoot`, reject an existing target, derive module path, write files, run `go mod tidy`, and preserve the pinned toolchain. `:219-250` parses `plugin.go` with `go/parser` and extracts the literal ID; use this when `<vendor.plugin>` is omitted. `:417-425` is the path containment check. `internal/build/manifest.go:167-189` rejects dangerous module paths.
For `registry.gen.go`, copy deterministic generation and idempotent output from `internal/build/build.go:126-165`:
```go
func formatSource(src []byte) ([]byte, error) {
formatted, err := format.Source(src)
if err != nil { return nil, fmt.Errorf("build: format generated go: %w", err) }
return formatted, nil
}
func writeIfChanged(path string, content []byte) error {
existing, err := os.ReadFile(path)
if err == nil && bytes.Equal(existing, content) { return nil }
if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("build: read %s: %w", path, err) }
if err := os.WriteFile(path, content, 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
```
The source builder in `scaffold.go:159-180` currently uses `strings.Builder` and `format.Source`; D-16 replaces the string builder with `go:embed` + `text/template` under `internal/build/stubs/*.tmpl`. There is no in-repo embedded-template analog. Preserve its error wrapping, formatting, `party.Register(&Plugin{})`, and `go.mod` generation (`:182-216`). Use parsed imports as in `scaffold.go:219-250` and `cmd/summer/main_test.go:42-69` for the `models/` sibling-import check. Run that check from `build.App` before the `go build` subprocess at `internal/build/build.go:33-56`, reporting plugin ID, source path, and import path.
**Generated registry contract:** Root `plugin.go` may import `models/`, `console/`, `jobs/`, `controllers/`, `updates/`; `models/` imports no sibling. Generated `registry.gen.go` owns only sorted/generated entries. Hand-written `plugin.go` must never be rewritten when a later `make:*` runs. If a hand-written plugin lacks accessors, create the registry and print the one-time accessor instruction (D-12). The existing `writeIfChanged` avoids rewriting identical bytes; use a temporary-file rename for registry updates as research recommends.
### Generated plugin artifacts
Use `examples/hello/plugins/base/plugin.go:3-30` as the root-plugin pattern:
```go
//go:embed config
var configFS embed.FS
type Plugin struct{}
func (p *Plugin) ID() string { return "golem15.hello" }
func (p *Plugin) Register(*backpack.App) error { return nil }
func (p *Plugin) Boot(*backpack.App) error { return nil }
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func init() { party.Register(&Plugin{}) }
```
Add `HasLang`/`HasMailTemplates` embedded FS accessors only when matching asset files exist; empty directories do not satisfy `go:embed`. For generated commands, copy the `bonfire.Command` returned by `examples/hello/plugins/greeter/plugin.go:78-119`, particularly `Run(ctx, in, out)` and colon command names. For migrations, the closest concrete gormigrate set is `lagoon/migrations_test.go:147-181`:
```go
&gormigrate.Migration{
ID: "202609170001_create_alpha",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE lagoon_alpha (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS lagoon_alpha`).Error
},
}
```
`pact.HasMigrations` returns an ordered `[]*gormigrate.Migration` (`pact/capabilities.go:23-28`); `lagoon/migrations.go:59-81` consumes it in activation order. The model stub should expose itself through `pact.HasModels` (`pact/capabilities.go:51-54`) and its create-table migration through the generated `updates/` set. `make:model --no-migration` omits only the migration. No production model, job, or admin-controller source exists in this repo, so D-13–D-15 and RESEARCH.md define their initial signatures.
### Optional capabilities and boot wiring — `pact`, `party`, `backpack`
Define `HasLang` and `HasMailTemplates` beside `pact.HasConfig` in `pact/capabilities.go:16-21`; its shape is `fs.FS` returned by an accessor. `pact/capabilities_test.go:12-16,87-100` shows `fstest.MapFS` fixtures and interface assertions. `party/registry.go:101-124` is the fixed capability/boot point:
```go
for _, p := range ordered {
if hc, ok := p.(pact.HasConfig); ok && app.Config != nil {
if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil {
return nil, fmt.Errorf("party: config %s: %w", p.ID(), err)
}
}
}
for _, p := range ordered {
if err := p.Register(app); err != nil { return nil, fmt.Errorf("party: register %s: %w", p.ID(), err) }
}
for _, p := range ordered {
if err := p.Boot(app); err != nil { return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) }
}
```
Place phrasebook/postcard asset loading so services are available to plugin `Boot`, while keeping `party` free of an import cycle. Preserve plugin order and name plugin ID plus missing template/layout in returned errors. `examples/hello/plugins/optional/plugin.go:21-30` publishes a service during `Register`; `backpack/app.go:59-74` and `backpack/services.go:20-59` define typed publish/lookup and duplicate-provider errors. `lagoon/connection.go:98-111` is another explicit service publisher. Publish translator and `postcard.Mailer` at app scope, not process scope.
### Phrasebook — `phrasebook/`
For embedded YAML loading, use `compass/config.go:288-332`: `fs.WalkDir`, sorted file paths, `fs.ReadFile`, contextual parse errors, then map conversion. Phrasebook must add the `lang/<locale>/<group>.yaml` path contract, flatten nested keys, and reject duplicate namespace ownership and malformed leaves. The existing config lookup pattern is `compass/config.go:112-164` (`Lookup`, `String`, `LoadSection`), but translation lookup must implement D-04's requested locale → parent → configured fallback → raw key sequence itself.
`towel/context.go:7-23,55-63` **already supplies** an unexported `localeKey`, `WithLocale`, and `Locale`. `surf/router.go:324-327` writes the `Accept-Language` header there. Use that accessor as the Phase 7 context seam; avoid a second context key that cannot read middleware values. There is no CLDR/pipe parser analog; follow D-02/D-03 and RESEARCH.md: go-i18n selects a category label, phrasebook substitutes `:name`, `:Name`, `:NAME` itself, and pipe conditions are parsed explicitly. `go.mod:6-23` already has `github.com/goccy/go-yaml v1.19.2`; go-i18n is a new dependency.
### Postcard — `postcard/`
Use the `compass/config.go:288-332` FS walk/read/error pattern for template registration, but write a dedicated INI-header/`==` parser: no production template parser exists. The actual mail format is fixed by D-06–D-09, including three layout sections and caller-selected `-en` siblings. `postcard.Mailer.Send(ctx, postcard.Message{...})` is a new service; use `lagoon/connection.go:82-111` for typed `mail.*` config loading and app publication. `compass/config.go:157-164` supports `LoadSection("mail", &opts)`; `compass/env.go:31-44` maps `SUMMER_MAIL__...` to `mail.*` automatically.
For driver errors, copy `lagoon/connection.go:26-48`: validate required settings early, wrap the underlying error with package context, clean up partial resources, and return errors to the caller. SMTP and Goldmark have no in-repo analog. D-07 and RESEARCH.md specify the render chain: `html/template` on Markdown source, Goldmark to HTML, substituted Markdown for text, and internally trusted layout HTML only. Test final HTML with HTML tags, Markdown links, and unsafe URLs; reject subject CR/LF before constructing mail headers. Keep explicit SMTP TLS policy and do not put credentials in log/error text.
### Test and fixture patterns
`internal/build/build_test.go:143-242` copies `examples/hello`, calls `MakePlugin`/`AddPlugin`, builds twice, and checks byte stability. The copy helper is at `:410-477`; it rewrites the framework `replace` before running Go commands. Extend this test to create every artifact type, run `go build` **and** `go vet`, check idempotent registry contents, and assert a `models/` sibling import is rejected. `assertScaffoldFiles` at `:340-408` currently treats any new file beyond `go.mod`, `plugin.go`, and `config/.gitkeep` as an error; update its allowlist for D-10/D-16.
Use `pact/capabilities_test.go:87-100` for `fstest.MapFS` translation/template fixtures. Use `examples/hello/hello_test.go:59-89` to prove service availability through `party.Activate`. `lagoon/postgres_test.go:25-39,92-103` is the Docker gate: full test fails when Docker is unavailable, `-short` skips. Its container start/terminate and bounded timeout at `:25-89` are the closest Mailpit pattern; Mailpit adds HTTP API polling after SMTP send. `go.mod:18-19` already includes testcontainers core; Mailpit is a new image/API fixture.
## Shared Patterns
| Concern | Source | Apply To |
|---|---|---|
| Plugin order and named boot errors | `party/registry.go:96-124` | phrasebook/postcard registration and asset validation |
| Optional FS capability | `pact/capabilities.go:16-28`, `examples/hello/plugins/base/plugin.go:12-26` | `HasLang`, `HasMailTemplates`, generated plugin |
| App-scoped services | `backpack/app.go:59-74`, `examples/hello/plugins/optional/plugin.go:21-30` | translator and mailer |
| Layered config/env | `compass/config.go:157-164`, `compass/env.go:31-44` | `app.locale`, `app.fallback_locale`, `mail.*` |
| Embedded file parsing | `compass/config.go:288-332` | translation YAML and mail assets |
| Safe generated paths and source | `internal/build/manifest.go:110-122,167-189`, `internal/build/build.go:146-165` | all `make:*` outputs |
| Locale context | `towel/context.go:55-63`, `surf/router.go:324-327` | phrasebook lookup seam |
| Integration test Docker gate | `lagoon/postgres_test.go:25-39,92-103` | Mailpit SMTP test |
## No Close Analog Found
| Target | Why research/decisions must supply the pattern |
|---|---|
| `internal/build/stubs/*.tmpl` | Existing generated Go uses string builders, not embedded templates. |
| `phrasebook/plural.go` | No CLDR category selection or Laravel pipe parser exists. |
| `postcard/smtp.go` | No SMTP sender exists. |
| `examples/hello/plugins/base/views/mail/*.htm` | No Winter shaped mail asset exists in this repository; use CONTEXT.md's PHP canonical reference. |
| Generated `jobs/`, admin `controllers/` and `fields.yaml`/`columns.yaml` | No framework job or admin-controller implementation exists yet; D-14/D-15 define minimum compile contracts. |
## Metadata
**Search scope:** `cmd/summer`, `internal/build`, `pact`, `party`, `backpack`, `compass`, `lagoon`, `surf`, `towel`, `examples/hello`, `go.mod`; scanned Go/YAML files with `rg --files` and capability/test patterns with `rg -n`.
**Pattern extraction date:** 2026-09-18.
**Boundary:** No changes to `../fonoteka.go`; Phase 3 flat plugins are not restructured here.