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