docs(14.2.1): create phase plan

This commit is contained in:
Jakub Zych
2026-10-06 11:22:45 +02:00
parent e723c391fb
commit f829d17dca
8 changed files with 1673 additions and 29 deletions

View File

@@ -0,0 +1,629 @@
# Phase 14.2.1: Translate plugin - Pattern Map
**Mapped:** 2026-10-06
**Files analyzed:** 42 new or modified files (plugin + framework + host + tests)
**Analogs found:** 40 / 42
Path roots:
- `FW/` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` (working directory; relative paths below are from here unless prefixed).
- `USR/` = `../fonoteka.go/plugins/golem15/user/` — the `sm-user-plugin` submodule. This is the plugin-mount analog RESEARCH named. `/media/nvme/dev/golem15/fonoteka.go/plugins/golem15/user/` does **not** exist.
- `BM/` = `../sm-bm-app/` — proof-host analog (`summer.yaml` + `go.work` + `.gitmodules` + `replace`). Copy this layout, not `fonoteka.go`.
- `PHP/` = `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate` at SHA `725d547ec839f02b5fdc0f0a6faaed601a414d50` (verified `git rev-parse HEAD` this session; 2026-08-26). Read-only contract. Do not edit.
- `TR/` = `../sm-translate-plugin/` — **does not exist yet** (D-16). Plan 01 creates it.
- `APP/` = `../sm-grzybyfunkcjonalne-app/` — **does not exist yet** (D-14). Plan 01 clones the empty remote.
All analog paths below are git-tracked (`git ls-files` in `summercms.go`, inside the user submodule, inside `sm-bm-app`, and inside the PHP plugin). No gitignored mirror is named. `modules/boardwalk/dist`, `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are generated outputs: regenerate them, never hand-edit.
**Table names (locked by RESEARCH against the frozen PHP models):** `winter_translate_locales`, `winter_translate_attributes`, `winter_translate_indexes`, `winter_translate_messages`. CONTEXT D-10's `golem15_translate_*` placeholder is wrong. Do not invent `golem15_translate_*`.
Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): **14.2.1-01** plugin repo + squashed schema + Locale + Translator + surf Resolver seam; **14.2.1-02** Translatable API + Locales admin + fixture; **14.2.1-03** cabana `markdown`/`mltext`/`mlmarkdown` + SPA + proof host boot; **14.2.1-04** unit/integration tests last.
## File Classification
### New plugin (`TR/` = `sm-translate-plugin`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `TR/go.mod` | config | — | `USR/go.mod` | exact |
| `TR/plugin.go` | provider | request-response | `USR/plugin.go` | exact |
| `TR/README.md` | docs | — | `USR/README.md` | exact |
| `TR/admin.go` | provider | — | `USR/admin.go` | exact |
| `TR/admin_permissions.go` | config | — | `USR/admin_permissions.go` | exact |
| `TR/admin_navigation.go` | config | — | `USR/admin_navigation.go` | exact |
| `TR/config/config.yaml` | config | — | `USR/config/config.yaml` + `PHP/config/config.php` | exact |
| `TR/lang/en/lang.yaml`, `TR/lang/pl/lang.yaml` | config | transform | `USR/lang/{en,pl}/lang.yaml` + `PHP/lang/en/lang.php` keys `plugin.*` / `locale.*` | exact |
| `TR/models/registry.go` | utility | — | `USR/models/registry.go` | exact |
| `TR/models/locale.go` | model | CRUD | `USR/models/user_group.go` (Fillable/Rules/TableName) + `PHP/models/Locale.php` (guards) | exact |
| `TR/models/attribute.go` | model | CRUD | `USR/models/api_token.go` shape + `PHP/models/Attribute.php` fillable | exact |
| `TR/updates/registry.go` | utility | — | `USR/updates/registry.go` | exact |
| `TR/updates/202610060001_create_winter_translate_locales.go` | migration | CRUD | `USR/updates/00_base.go` + `USR/updates/202610020001_create_user_groups.go` (CREATE + seed style) | exact |
| `TR/updates/202610060002_create_winter_translate_attributes.go` | migration | CRUD | `USR/updates/00_base.go` | exact |
| `TR/updates/202610060003_create_winter_translate_indexes.go` | migration | CRUD | `USR/updates/00_base.go` | exact |
| `TR/updates/202610060004_create_winter_translate_messages.go` | migration | CRUD | `USR/updates/00_base.go` (DDL only; no Message admin) | exact |
| `TR/updates/202610060005_seed_en_pl_locales.go` | migration | CRUD | `USR/updates/202610020001_create_user_groups.go` INSERT + `PHP/updates/v1.3.1/seed_all_tables.php` and `v2.4.0/seed_additional_locales.php` | exact |
| `TR/classes/translator.go` | service | request-response | `PHP/classes/Translator.php` + `LocaleMiddleware.php` + `ApiLocaleMiddleware.php` (contract) and `USR/plugin.go` Boot `app.Publish` | role-match |
| `TR/classes/translatable.go` | service | CRUD | `PHP/behaviors/TranslatableModel.php` + `classes/TranslatableBehavior.php` (no Go translatable analog) | partial |
| `TR/controllers/admin_registry.go` | config | — | `USR/controllers/admin_registry.go` | exact |
| `TR/controllers/locales.go` | controller | CRUD | `USR/controllers/usergroups_admin_controller.go` | exact |
| `TR/controllers/locales/config_list.yaml` | config | file-I/O | `USR/controllers/usergroups/config_list.yaml` + `PHP/controllers/locales/config_list.yaml` | exact |
| `TR/controllers/locales/config_form.yaml` | config | file-I/O | `USR/controllers/usergroups/config_form.yaml` + `PHP/controllers/locales/config_form.yaml` | exact |
| `TR/models/locale/fields.yaml` | config | file-I/O | `USR/models/usergroup/fields.yaml` + `PHP/models/locale/fields.yaml` | exact |
| `TR/models/locale/columns.yaml` | config | file-I/O | `USR/models/usergroup/columns.yaml` + `PHP/models/locale/columns.yaml` | exact |
### Framework (`FW/`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `modules/surf/router.go` (`locale()` seam) | middleware | request-response | same file `locale` 707-710 and `wrap` 439; `backpack.Lookup` from `USR/plugin.go` Boot | exact |
| `modules/surf/locale_resolver.go` (new interface, if extracted) | middleware | request-response | `modules/surf/locale_from_principal.go` + `modules/pact/capabilities.go` `HasHouseMiddleware` (do **not** use house-MW to replace `locale()`) | role-match |
| `modules/cabana/form_schema.go` | config compiler | transform | same file `formFieldTypes` 24-29, `compileFieldNode` 454-478 | exact |
| `modules/cabana/crud.go` | service | CRUD | same file `ProjectWritableFields` 154-176, `scalarFormField` 1203-1209, `save` lifts 575-590 | exact |
| `modules/cabana/field_ml.go` (new: `markdown` / `mltext` / `mlmarkdown`) | service | transform + CRUD | `modules/cabana/field_permission.go` (`liftPermissionValues` nested object) + `field_date.go` (`compileDatepickerKeys`) | role-match |
| `modules/cabana/README.md` | docs | — | same file Features list | exact |
| `docs/backend/forms.md` | docs | — | same file Field types table 86-101 | exact |
| `docs/backend/admin-controllers.md` | docs | — | same file Compilation at boot 159-161 (only if activation rules change) | exact |
| `admin/src/components/form/registry.ts` | config (registry) | transform | same file 49-63 | exact |
| `admin/src/components/form/fields/MarkdownField.vue` | component | request-response | `admin/src/components/form/fields/TextareaField.vue` | exact |
| `admin/src/components/form/fields/MLTextField.vue` | component | request-response | `TextField.vue` (editor) + `PHP/formwidgets/mltext/partials/_mltext.htm` (chrome) | role-match |
| `admin/src/components/form/fields/MLMarkdownField.vue` | component | request-response | MarkdownField + ML chrome (compose; do not duplicate markdown) | role-match |
| `admin/src/components/form/formState.ts` | utility | transform | same file `editablePayload` 50-69 (permissioneditor nested object already sent) | exact |
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist/` | generated | — | regenerate; never analog-copy | — |
### Proof host (`APP/` = `sm-grzybyfunkcjonalne-app`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `APP/go.mod` | config | — | `BM/go.mod` | exact |
| `APP/go.work` | config | — | `BM/go.work` | exact |
| `APP/summer.yaml` | config | — | `BM/summer.yaml` | exact |
| `APP/.gitmodules` | config | — | `BM/.gitmodules` | exact |
| `APP/main.go`, `APP/plugins.gen.go` | route | — | `BM/main.go`, `BM/plugins.gen.go` (`summer build` emits these; do not hand-author after first boot) | exact |
### Tests (plan 04 last)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `TR/admin_harness_test.go` + Locales admin tests | test | request-response | `USR/admin_harness_test.go` `newAdminEnv` | exact |
| `TR/updates/postgres_test.go` | test | CRUD | `USR/updates/postgres_test.go` TestMain + testcontainers | exact |
| `TR/classes/*_test.go` Translator + Translatable | test | request-response / CRUD | PHP `tests/unit/behaviors/TranslatableModelTest.php` landmines (lean subset) | partial |
| `modules/cabana/*_test.go` ML compile/save | test | transform | `modules/cabana/form_schema_test.go` `TestFormSchemaRejects` | exact |
| `modules/surf/*_test.go` Resolver present vs absent | test | request-response | `modules/surf/locale_from_principal_test.go` | exact |
| `admin/tests/form/registry.test.ts` + ML field tests | test | — | `admin/tests/form/registry.test.ts`, `admin/tests/form/DatepickerField.test.ts` | exact |
## Pattern Assignments
### `TR/go.mod` (config) — plan 01
**Analog:** `USR/go.mod` lines 1-14, 120
```
module git.golem15.com/golem15/sm-user-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.0.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7
...
gorm.io/gorm v1.31.2
)
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
```
Copy: module `git.golem15.com/golem15/sm-translate-plugin`, Go 1.27.0, require GORM + gormigrate + summercms, **identical** `replace … => ../../../../summercms.go` when mounted at `plugins/golem15/translate`. Do not add goldmark here unless the plugin itself parses markdown; goldmark stays a framework dep.
Decision note: `.planning/notes/core-plugins-own-repos.md` lines 12-15 — module path equals repo path; package `translate`; plugin ID `golem15.translate`. README never names a consuming app.
---
### `TR/plugin.go` (provider) — plan 01
**Analog:** `USR/plugin.go` lines 28-64, 178-180, 207-209, 236-238
```go
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
//go:embed lang
var langFS embed.FS
func (p *Plugin) ID() string { return "golem15.user" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
func init() { party.Register(&Plugin{}) }
```
Copy: `ID() "golem15.translate"`, `Requires()` empty (Translator works without user; locale-from-user is optional). **Do not** copy JWT/bouncer/mail/commands/middleware from user. **Do** `app.Publish` a Resolver in `Boot` (see Translator assignment). Admin capability assertions live in `admin.go`, not here.
PHP contract (do **not** port): `PHP/Plugin.php` `registerComponents`, `manage_messages`, `registerFormWidgets`, console commands, CMS extend — all deferred. Port only `manage_locales` permission (lines 71-75) and Locales as admin CRUD, not `HasSettings` (PHP `registerSettings` points at a list controller; cabana `HasSettings` is a singleton — RESEARCH §2).
---
### `TR/admin.go` + permissions + navigation (provider / config) — plan 02
**Analog:** `USR/admin.go` lines 12-38, `USR/admin_permissions.go` 14-21, `USR/admin_navigation.go` 11-19
```go
//go:embed controllers/usergroups/config_list.yaml controllers/usergroups/config_form.yaml models/usergroup/fields.yaml models/usergroup/columns.yaml
var adminFS embed.FS
func (p *Plugin) AdminFS() fs.FS { return adminFS }
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App { return p.app })
}
var (
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
)
```
Permissions analog (`USR/admin_permissions.go`):
```go
{
Code: "golem15.users.access_users",
Tab: userPermissionTab,
Label: "golem15.user::lang.plugin.access_users",
Roles: []string{"developer"},
},
```
Translate: `Code: "golem15.translate.manage_locales"`, tab `golem15.translate::lang.plugin.tab`, label `golem15.translate::lang.plugin.manage_locales`, `Roles: []string{"developer"}`. **Do not** register `golem15.translate.manage_messages` this phase.
Navigation analog: main item `Code: "translate"`, `Icon` lucide (`languages` or similar; PHP was `icon-language`), `Permissions: []string{"golem15.translate.manage_locales"}`, `Controller: "golem15.translate.locales"`, `Order` ~550 (PHP settings order 550). Locales is CRUD list+form, not a settings singleton.
---
### `TR/models/locale.go` (model, CRUD) — plan 01/02
**Analog (Go struct + Fillable + Rules):** `USR/models/user_group.go` lines 9-50
```go
type UserGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code *string `gorm:"column:code"`
// ...
}
func (UserGroup) TableName() string { return "user_groups" }
func (UserGroup) Fillable() []string { return []string{"name", "code", "description"} }
func (UserGroup) Rules() map[string]string {
return map[string]string{"name": "required|between:3,64", "code": "required|unique:user_groups"}
}
func init() { Register(UserGroup{}) }
```
**Contract (PHP):** `PHP/models/Locale.php` lines 24-43, 77-109
```php
public $table = 'winter_translate_locales';
public $rules = ['code' => 'required', 'name' => 'required'];
public $fillable = ['code', 'name', 'is_enabled'];
public $timestamps = false;
// beforeDelete: cannot delete default
// beforeUpdate: cannot unset default; makeDefault() writes is_default
// makeDefault: cannot make a disabled locale default
```
Copy: `TableName() "winter_translate_locales"`, no `CreatedAt`/`UpdatedAt`, Fillable **only** `code`, `name`, `is_enabled`. `is_default` and `sort_order` are **not** fillable (PHP). Rules: `code` required, `name` required. Port delete/unset/disabled-default as `FormBeforeDelete` / `FormBeforeUpdate` returning `&cabana.ValidationError{Details: ...}` (422) — analog `USR/controllers/usergroups_admin_controller.go` 162-175.
`isValid` = code in enabled list (`PHP/models/Locale.php` 228-232). Default locale: `is_default` true row; seed `en` as default.
---
### `TR/models/attribute.go` (model, CRUD) — plan 02
**Contract:** `PHP/models/Attribute.php` lines 15-34 — table `winter_translate_attributes`, fillable `locale`, `model_type`, `model_id`, `attribute_data`, `$guarded = ['*']`.
**Go analog:** `USR/models/user_group.go` TableName + Register. No public Attribute controller. Writes only through Translatable helpers (RESEARCH T-SEC). Optional: omit a Message model entirely and only DDL `winter_translate_messages` (RESEARCH §9). If Attribute exists, Fillable matches PHP; never expose an admin controller.
---
### `TR/updates/*` (migration, CRUD) — plan 01
**Analog (CREATE + Register):** `USR/updates/00_base.go` lines 1-33
```go
var migrations = []*gormigrate.Migration{
{
ID: "202609170001_create_users",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
...
)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS users`).Error
},
},
}
func init() { Register(migrations...) }
```
**Analog (indexes + idempotent seed):** `USR/updates/202610020001_create_user_groups.go` lines 17-45 — `CREATE INDEX …`, `INSERT INTO … VALUES`.
**Contract columns (squash to final names; do not emit `rainlab_translate_*`):**
| Table | Columns (from PHP create + later updates) |
|-------|-------------------------------------------|
| `winter_translate_locales` | `id SERIAL PK`, `code TEXT` indexed, `name TEXT` indexed nullable, `is_default BOOLEAN DEFAULT FALSE`, `is_enabled BOOLEAN DEFAULT FALSE`, `sort_order INTEGER DEFAULT 0`. No timestamps. |
| `winter_translate_attributes` | `id`, `locale` indexed, `model_id` indexed nullable, `model_type` indexed nullable, `attribute_data TEXT` nullable |
| `winter_translate_indexes` | `id`, `locale` indexed, `model_id` indexed nullable, `model_type` indexed nullable, `item` indexed nullable, `value TEXT` nullable |
| `winter_translate_messages` | `id`, `code` indexed nullable, `message_data TEXT` nullable, `found BOOLEAN DEFAULT TRUE`, `code_pre_2_1_0 TEXT` indexed nullable — create empty; no Messages admin |
Suggested IDs (planner may adjust date prefix, not table names): `202610060001_create_winter_translate_locales` … `202610060005_seed_en_pl_locales`.
**Seed contract:** `PHP/updates/v1.3.1/seed_all_tables.php` 16-22 (`en` / English / default / enabled) and `PHP/updates/v2.4.0/seed_additional_locales.php` 13-20 (`pl` / Polski / not default / enabled / `sort_order` 2). Set `en.sort_order = 1`. **Do not seed `de`.** Idempotent: insert by `code` if missing (`$exists` check lines 33-36).
---
### `TR/classes/translator.go` (service, request-response) — plan 01
**Contract order (do not reduce):** `PHP/classes/LocaleMiddleware.php` 24-41
1. URL prefix (`Translator::loadLocaleFromRequest` — first path segment, only if `Locale::isValid`) — `PHP/classes/Translator.php` 129-138
2. Else authenticated user's `preferred_locale` if valid — middleware 54-83
3. Else session `golem15.translate.locale` — Translator 204-213
4. Else Accept-Language **only if** `browserDetection.enabled` **and** cookie `locale_manually_set` is absent — middleware 33-36, 96-100, 214-228. Parse q-values; take first two letters; allow-list against enabled codes (126-132). **Do not** pass the raw header into `towel.WithLocale`.
5. Else default locale.
API chain (ship Translator so Phase 15 can call it): `PHP/classes/ApiLocaleMiddleware.php` 40-59 — user preferred → Accept-Language → default; `setLocale($locale, false)` no session.
Keys (`PHP/classes/Translator.php` 23-25, `config/config.php` 77-86):
- session/cookie locale: `golem15.translate.locale`
- configured flag: `golem15.translate.configured`
- manual-selection cookie **flag only**: `locale_manually_set` = `'1'`, expiry 525600 minutes. Does **not** contain a locale code. URL-prefix visit queues it (`PHP/routes.php` 29-35).
`setLocale` requires `Locale::isValid`; invalid codes return false and are never persisted (`Translator.php` 58-72).
**Go seam analog:** `USR/plugin.go` Boot Publish (lines 77-95) + `FW/modules/backpack/app.go` 59-73
```go
func (a *App) Publish[T any](value T) error { return a.Services.Publish(value) }
func (a *App) Lookup[T any]() (T, bool) { ... }
```
User plugin publishes `*bouncer.Registry` / `bouncer.BlacklistStore`. Translate Boot publishes a `surf.LocaleResolver` (or equivalent interface **in the framework**, because `surf` must not import the plugin). Methods take `ctx` and `*http.Request`; they must not store the active locale on a process-wide struct (KERN-07). Write the resolved code with `towel.WithLocale`.
**Do not** implement PHP Singleton `Translator::instance()`. **Do not** use `pact.HasHouseMiddleware` to replace house `locale()`: house-MW is a named middleware table (`FW/modules/pact/capabilities.go` 47-59); `locale()` is hardcoded in `wrap` after named MW (`router.go` 439).
Config keys under plugin namespace (`PHP/config/config.php`): `forceDefaultLocale`, `prefixDefaultLocale` (default true), `disableLocalePrefixRoutes` (default false), `browserDetection.enabled` (default true), `browserDetection.manualSelectionCookie`, `browserDetection.manualSelectionExpiry`. Port into `TR/config/config.yaml` via `HasConfig` like `USR/config/config.yaml`.
---
### `modules/surf/router.go` locale seam (middleware) — plan 01
**Analog (current, to wrap):** `FW/modules/surf/router.go` 439 and 707-710
```go
h = locale(h)
func locale(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))))
})
}
```
`wrap` currently has `*Router` but **no** `*backpack.App`. `BuildRouter(app, plugins)` has `app` (459-464). Prescription: store `app` on `Router` during `BuildRouter`, then `locale()` Lookup Resolver; if published, call it and write a **validated** code; else keep today's Accept-Language behavior so `fonoteka.go` (no translate plugin) does not change.
**Existing overlay (keep when no Resolver):** `FW/modules/surf/locale_from_principal.go` 10-18 — `PreferredLocale` on the principal. With Resolver, user preferred is step 2 of the PHP chain (after URL prefix), not a post-hoc overlay of the raw header.
**Context bag:** `FW/modules/towel/context.go` 55-62
```go
func WithLocale(ctx context.Context, locale string) context.Context {
return withValue(ctx, localeKey{}, locale)
}
func Locale(ctx context.Context) (string, bool) {
return stringValue(ctx, localeKey{})
}
```
Phrasebook UI locale and model content locale usually match after Translator runs; Translatable helpers still take an **explicit** locale argument (admin SPA UI can stay `en` while content is `pl`).
**Cookie attributes analog (not JWT):** `FW/modules/cabana/auth.go` 243-252 — `HttpOnly`, `Secure`, `SameSite`. Locale cookies are not admin session tokens; re-validate enabled codes every request. RESEARCH A3: signed cookie `golem15.translate.locale` is the Go stand-in for Laravel session; do not invent a second JWT library.
---
### `TR/classes/translatable.go` (service, CRUD) — plan 02
**No Go translatable analog.** Copy PHP storage, not Eloquent magic.
**Contract:** `PHP/classes/TranslatableBehavior.php` 141-216 and `PHP/behaviors/TranslatableModel.php` 89-108, 224-294, 345-348
- Default locale values live on the **host model's own columns**. `isTranslatable` is false when context equals default.
- Other locales: JSON object in `winter_translate_attributes.attribute_data`, one row per `(locale, model_id, model_type)`.
- Missing key + fallback on (default): return default-locale column.
- Indexed attributes (`['slug', 'index' => true]`) also write `winter_translate_indexes` (`item` = attribute, `value` = translated string).
- `model_type` = `getMorphClass()` → Go `MorphName() string`.
- `scopeTransWhere`: look up index table; if no rows, `where` on the host column (104-108).
- Do **not** port Redis `translation:%s:%s:%s` cache this phase.
**MorphName analog:** `USR/models/user.go` 59-60 and `FW/modules/lagoon/attach/example_test.go` 21
```go
func (User) MorphName() string { return `Golem15\User\Models\User` }
func (Post) MorphName() string { return `Acme\Blog\Models\Post` }
```
Fixture models may use a Go type string. Document that Phase 15 Journal import must pin PHP class strings (`Golem15\Journal\Models\Post`).
Minimum exported API (RESEARCH §3; identifiers prescribed there):
```go
type Translatable interface {
Translatable() []string
MorphName() string
}
func WithLocale(ctx context.Context, db *gorm.DB, locale string) *gorm.DB
func Translated(...) (any, error)
func SetTranslated(...) error
```
Plus `TranslatableIndexes() []string` (or options) for Journal slugs. Do not 1:1 every PHP method. Do not export deprecated `noFallbackLocale`.
---
### `TR/controllers/locales.go` (controller, CRUD) — plan 02
**Analog:** `USR/controllers/usergroups_admin_controller.go` 41-53, 123-131, 162-175 + `USR/controllers/admin_registry.go` 12-18, 42-50
```go
func (usergroupsAdminController) ID() string { return "golem15.user.usergroups" }
func (usergroupsAdminController) ModelName() string { return `Golem15\User\Models\UserGroup` }
func (usergroupsAdminController) ConfigDir() string { return "controllers/usergroups" }
func (usergroupsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessGroups}
}
func (usergroupsAdminController) NewRecord() any { return &models.UserGroup{} }
```
Translate: `ID() "golem15.translate.locales"`, `ModelName() \`Golem15\Translate\Models\Locale\``, `ConfigDir() "controllers/locales"`, `RequiredPermissions() []string{"golem15.translate.manage_locales"}`, `NewRecord() &models.Locale{}`.
PHP `Locales.php` 21: `$requiredPermissions = ['golem15.translate.manage_locales']`. Implement `pact.AdminPermissioned`. 403 without it.
Hooks: `FormBeforeDelete` refuse default locale; `FormBeforeUpdate` refuse unsetting default / making disabled default — return `&cabana.ValidationError` (422) with phrase keys from `PHP/lang/en/lang.php` 27-29 (`unset_default`, `delete_default`, `disabled_default`). Analog Forbidden vs Validation: usergroups uses `ForbiddenError` for permission (403) and `ValidationError` for code format (422). Locale guards are validation, not 403.
PHP ReorderController has **no cabana analog**. Keep `sort_order`, seed `en=1` `pl=2`, list `defaultSort` `sort_order` asc. Do not invent drag-reorder.
**Error types:** `FW/modules/cabana/crud.go` 62-81 `ValidationError` / `ForbiddenError`.
---
### Admin YAML (config, file-I/O) — plan 02
**Go analog:** `USR/controllers/usergroups/config_list.yaml` (recordUrl, recordsPerPage 20, toolbar create, search) and `config_form.yaml` (form path, modelClass, redirects).
**PHP contract fields:** `PHP/models/locale/fields.yaml` — `name`, `code`, `is_enabled` checkbox, `is_default` checkbox. `PHP/models/locale/columns.yaml` — name/code searchable, is_default switch, is_enabled switch invisible, sort_order number invisible.
Cabana list types (`FW/modules/cabana/list_schema.go` 22-24): `"text"`, `"datetime"`, `"switch"`, `"date"`, `"time"`. Map PHP `type: number` on invisible `sort_order` → omit type (text) or drop from visible columns.
`config_list.yaml`: PHP `recordOnClick` is Winter AJAX; Go uses `recordUrl: golem15/translate/locales/update/:id` like usergroups. `title: golem15.translate::lang.locale.label_plural`. `defaultSort.column: sort_order`, `direction: asc`.
Embed YAML file-by-file in `admin.go` (`//go:embed controllers/locales/... models/locale/...`), same as user — do not embed the whole `controllers/` tree (it will hold `.go` files).
---
### Phrasebook lang YAML (config) — plan 02
**Analog:** `USR/lang/en/lang.yaml` `plugin:` block lines 4-10 and `USR/lang/pl/lang.yaml` same keys.
**Contract keys to port (UI only):** `PHP/lang/en/lang.php` 4-41 `plugin.name/description/tab/manage_locales` and `locale.*` (label, label_plural, title, create/update titles, name, code, is_default, is_enabled, help, delete/unset/disabled_default, sort_order, hint). Do **not** load PHP `unsupported_lang/` or `messages.*` this phase.
Do not conflate phrasebook files with Locale seed rows (D-08 is two Locale rows; two YAML trees for plugin UI).
---
### Cabana `markdown` / `mltext` / `mlmarkdown` — plan 03
**Registry analog:** `FW/modules/cabana/form_schema.go` 24-47 and 465-478
```go
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, ... "datepicker": {}, "password": {}, "permissioneditor": {},
}
// unknown key → "unknown field %s"
// unknown type → "unsupported type %s"
```
Add `"markdown"`, `"mltext"`, `"mlmarkdown"` to `formFieldTypes`. Prefer **no extra YAML keys** (reuse `label`, `comment`, `span`, `size`, `required`, `tab`, `context`). If a key is added, it must go in `formFieldKeys` or boot fails.
**Compile analog:** `FW/modules/cabana/field_date.go` 42-52 `compileDatepickerKeys` — refuse type-specific keys on other types; call from `compileFieldNode` next to `compilePermissionKeys` / `compileDatepickerKeys` (form_schema.go 551-558).
**Nested save analog:** `FW/modules/cabana/field_permission.go` 177-210 `liftPermissionValues` — read `body[name]` as `map[string]any` **before** scalar projection. Hook the lift from `CRUDService.save` (`crud.go` 575-590) the same way relations/virtual/permissions are lifted.
**The landmine:** `ProjectWritableFields` (`crud.go` 154-176) drops `nestedValue` (maps/slices, 1224-1230). `scalarFormField` (1203-1209) is only `text/textarea/number/checkbox/switch/dropdown/datepicker` — `mltext`/`mlmarkdown`/`markdown` are skipped by `BindWritableFields` (201) unless treated as scalar **or** lifted like permissioneditor.
**PHP save contract:** `PHP/traits/MLControl.php` 186-216 — POST all locales; `setAttributeTranslated` per locale; return value is the **default locale** entry only. `getLocaleValue` uses `setTranslatableUseFallback(false)` so empty translations stay empty in hidden fields (158-159). Host column = default locale; attribute rows = other locales only (do not duplicate default into `winter_translate_attributes`).
**SPA registry analog:** `FW/admin/src/components/form/registry.ts` 49-63 — add `markdown`, `mltext`, `mlmarkdown`. `isRegistered` must stay true so `formState.editablePayload` (50-69) includes them. permissioneditor already sends a nested object whenever shown (60-63) — ML fields should send `Record<locale, string>` the same way.
**Chrome contract:** `PHP/formwidgets/mltext/partials/_mltext.htm` — wrapper `data-default-locale`, locale selector, hidden inputs per locale, copy-from-locale optional. One switcher per ML field; switching one switches all (PHP Ctrl/Cmd-click). Visible editor shows the active locale.
**Markdown primitive analog:** `FW/modules/postcard/templates.go` 24-29 `goldmark.New()` already in framework `go.mod` v1.8.6. Land shared `markdown` **this phase** (`mlmarkdown` = markdown editor + ML chrome). Safe mode: no `html.WithUnsafe`; reject leftover script/iframe (postcard `rawUnsafeTag`). Minimum this phase is edit+save of markdown source per locale, not WYSIWYG.
**Docs analog:** `FW/docs/backend/forms.md` 86-101 — add the three types; **replace** the sentence that the markdown editor is not provided (line 101). Neutral names only (`acme`, `blog`). Same change: `modules/cabana/README.md` Features, OpenAPI, openapi-typescript, committed `boardwalk/dist/`. `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`.
**Fail-loud test analog:** `FW/modules/cabana/form_schema_test.go` `TestFormSchemaRejects` 184-197 (`unknown key` / `unknown type`). Add `type: mlunknown` fails boot.
---
### SPA field components — plan 03
**Text analog:** `FW/admin/src/components/form/fields/TextField.vue` 1-26 — `FieldControlProps`, `update:modelValue`, `controlAttributes` / `controlClass`.
**Textarea analog for markdown source:** `FW/admin/src/components/form/fields/TextareaField.vue` 1-30 — size → rows.
**Value-field test analog:** `FW/admin/tests/form/DatepickerField.test.ts` mount + emit; `FW/admin/tests/form/registry.test.ts` `it.each` renderer map (31-42).
No existing multilingual control. Compose: inner TextField/MarkdownField + locale switcher chrome. `modelValue` is `Record<string, string>` (locale → text), not a scalar.
---
### Proof host (`APP/`) — plan 03 (clone in plan 01)
**Analog:** `BM/go.mod` 1-20, `BM/go.work` 5-10, `BM/summer.yaml` 1-9, `BM/.gitmodules` 1-3, `BM/main.go` 22-38, `BM/plugins.gen.go` 1-16
```
module git.golem15.com/jakub/sm-bm-app
replace git.golem15.com/golem15/summercms => ../summercms.go
replace git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user
```
```yaml
plugins:
- id: golem15.user
module: git.golem15.com/golem15/sm-user-plugin
```
```
[submodule "plugins/golem15/user"]
path = plugins/golem15/user
url = git@git.golem15.com:golem15/sm-user-plugin.git
```
```go
plugins, err := party.Activate(app, PluginIDs)
```
Proof host: module `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app` (or whatever the empty remote uses), plugins **only** `golem15.user` + `golem15.translate`, submodule paths `plugins/golem15/user` and `plugins/golem15/translate`, `go.work` use both, `replace` both to `./plugins/...`, framework replace `../summercms.go`. `main.go` / `plugins.gen.go` come from `summer build` after `summer.yaml` exists (`FW/examples/hello/plugins.gen.go` same stamp `// Code generated by summer build. DO NOT EDIT.`).
D-17 fixture: RESEARCH A1 — prefer plugin integration test (`party.Activate([]{user, translate, in-process fixture})`) plus host smoke `migrate`/`serve` without a third production plugin. Do not block on a host demo model.
Manual Wave 0: user creates `git@git.golem15.com:golem15/sm-translate-plugin.git` (does not exist). Host remote already exists.
---
### Tests — plan 04
**Admin boot analog:** `USR/admin_harness_test.go` 129-191 `newAdminEnv` — `party.Activate`, `lagoon.Migrate`, `surf.Assemble`, mint backend admin with a permission set. Locales 403 without `golem15.translate.manage_locales`.
**Postgres analog:** `USR/updates/postgres_test.go` 25-36 TestMain + testcontainers `postgres:16-alpine`, `-short` skip.
**Surf analog:** `FW/modules/surf/locale_from_principal_test.go` — table of Resolver-absent (raw Accept-Language still set, fonoteka) vs Resolver-present (URL wins; invalid prefix ignored; cookie flag skips Accept-Language; unlisted code rejected).
Lean PHP landmines (do not 1:1 the suite): fallback default; set `pl` does not change default column; `WithLocale` + indexed slug; skip morphMap, `addTranslatableAttributes`, Messages, CMS page/url.
## Shared Patterns
### Plugin mount (submodule + go.work + replace)
**Source:** `USR/go.mod`, `BM/go.mod` / `go.work` / `summer.yaml` / `.gitmodules`, `.planning/notes/core-plugins-own-repos.md`
**Apply to:** `TR/` repo creation and `APP/` first mount
Module `git.golem15.com/golem15/sm-translate-plugin`, package `translate`, ID `golem15.translate`, `party.Register` in `init`, `summer.yaml` lists the id+module, submodule `plugins/golem15/translate`, plugin `replace` framework `../../../../summercms.go`, host `replace` plugin `./plugins/golem15/translate`.
### Backpack Publish / Lookup (optional Translator)
**Source:** `FW/modules/backpack/app.go` 59-73; `USR/plugin.go` Boot 77-95
**Apply to:** Translator Resolver; surf `locale()` Lookup
Interface lives in **framework** (`surf` or a tiny contract next to `locale()`). Plugin Boot publishes. Surf looks up; if missing, today's Accept-Language. No process-wide locale (KERN-07).
### Request locale bag
**Source:** `FW/modules/towel/context.go` 55-62
**Apply to:** Translator and phrasebook
Always `towel.WithLocale(ctx, validatedCode)`. Never `var currentLocale`. Never stuff the raw `Accept-Language` header into context when Resolver is present.
### Admin CRUD + permissions
**Source:** `USR/admin.go`, `usergroups_admin_controller.go`, `pact.HasAdminControllers` / `AdminPermissioned` (`FW/modules/pact/capabilities.go` 182-195)
**Apply to:** Locales controller
YAML + `CRUDService`. `RequiredPermissions` `golem15.translate.manage_locales`. Fillable allow-list. Lifecycle hooks return `ValidationError` (422) or `ForbiddenError` (403). Fail-loud YAML at `cabana.Activate` (`docs/backend/admin-controllers.md` 161).
### Nested form values (ML save)
**Source:** `FW/modules/cabana/field_permission.go` `liftPermissionValues`; `crud.go` `save` lifts before `ProjectWritableFields`
**Apply to:** `mltext` / `mlmarkdown` locale maps
Lift `map[string]string` per ML field before nested drop. Default locale → host column; other locales → `SetTranslated`. Do not bind ML maps as extra model columns.
### Morph type strings
**Source:** `USR/models/user.go` `MorphName`; `FW/modules/lagoon/attach/example_test.go`
**Apply to:** `winter_translate_attributes.model_type` / indexes
Explicit `MorphName() string`. Do not use `reflect.TypeOf(x).String()`.
### Phrasebook vs model translations
**Source:** `USR/lang/{en,pl}/lang.yaml`; `FW/modules/phrasebook` (HasLang)
**Apply to:** Locales UI strings only
`I18N-01` is phrasebook. Attribute JSON is the model layer. Seed Locale **rows** independently of YAML files.
### Markdown
**Source:** `FW/modules/postcard/templates.go` goldmark pipeline
**Apply to:** cabana `markdown` / `mlmarkdown` preview if any
One library (`github.com/yuin/goldmark` v1.8.6). Safe HTML. No second markdown parser.
### Cookie flags
**Source:** `FW/modules/cabana/auth.go` `sessionCookie` 243-252
**Apply to:** `locale_manually_set` and locale cookie
HttpOnly + Secure + SameSite. Manual cookie is flag `"1"`, not a locale code. Re-validate locale cookies against enabled list every request.
### Test harness
**Source:** `USR/admin_harness_test.go` `newAdminEnv`; `USR/updates/postgres_test.go`
**Apply to:** plugin admin + migration tests last
`party.Activate` + `lagoon.Migrate` + `surf.Assemble`. testcontainers behind `-short`.
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `TR/classes/translatable.go` (attribute JSON + `WithLocale` index lookup) | service | CRUD | No Go model currently stores translations in `winter_translate_*`. Copy PHP `TranslatableModel` / `TranslatableBehavior` storage; use `MorphName` + GORM only as structural analogs. |
| `admin/.../MLTextField.vue` / `MLMarkdownField.vue` locale switcher | component | request-response | No multilingual SPA control exists. Compose TextField/TextareaField + PHP `_mltext.htm` chrome; nested save follows permissioneditor. |
Planner should use RESEARCH.md §3–§5 for those two, not invent JSON columns or `type: widget` ML controls.
## Do not copy / anti-patterns
- **`golem15_translate_*` table names** — frozen PHP uses `winter_translate_*`.
- **JSON column on host models** — D-10.
- **Translator singleton / package-level request locale** — KERN-07.
- **Accept-Language as the only resolver** — D-07; also stop stuffing the raw header into context when Resolver runs.
- **Plugin-owned `type: widget` ML controls** — field types belong in cabana (`formFieldTypes`).
- **`HasSettings` for Locales** — it is CRUD; use `HasAdminControllers` + `HasNavigation`.
- **`HasHouseMiddleware` as the locale seam** — house-MW is named; `locale()` is hardcoded in `wrap`.
- **Messages admin, locale picker, AI/theme commands, message import** — deferred.
- **Seeding `de`** — D-08 is `en`+`pl` only.
- **Editing `wn-translate-plugin` / PHP tree**.
- **AutoMigrate as schema** — gormigrate squash only.
- **Hand-editing `boardwalk/dist` or OpenAPI JSON**.
- **Naming a consuming app in the plugin README**.
## Metadata
**Analog search scope:** `USR/` (sm-user-plugin), `BM/` (sm-bm-app), `FW/modules/{cabana,surf,towel,backpack,pact,postcard,lagoon}`, `FW/admin/src/components/form`, `FW/docs/backend`, `PHP/` translate plugin at `725d547ec839f02b5fdc0f0a6faaed601a414d50`
**Files scanned:** ~90 analog files read or grepped; 3–5 strong matches per new file; PHP pin SHA verified
**Pattern extraction date:** 2026-10-06
**Tracked-source gate:** every analog path printed by `git ls-files` in its own repository