38 KiB
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/— thesm-user-pluginsubmodule. 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, notfonoteka.go.PHP/=/media/nvme/dev/golem15/fonoteka/plugins/golem15/translateat SHA725d547ec839f02b5fdc0f0a6faaed601a414d50(verifiedgit rev-parse HEADthis 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
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: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):
{
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
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
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
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
- URL prefix (
Translator::loadLocaleFromRequest— first path segment, only ifLocale::isValid) —PHP/classes/Translator.php129-138 - Else authenticated user's
preferred_localeif valid — middleware 54-83 - Else session
golem15.translate.locale— Translator 204-213 - Else Accept-Language only if
browserDetection.enabledand cookielocale_manually_setis 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 intotowel.WithLocale. - 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.php29-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
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
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
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.
isTranslatableis 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 writewinter_translate_indexes(item= attribute,value= translated string). model_type=getMorphClass()→ GoMorphName() string.scopeTransWhere: look up index table; if no rows,whereon the host column (104-108).- Do not port Redis
translation:%s:%s:%scache this phase.
MorphName analog: USR/models/user.go 59-60 and FW/modules/lagoon/attach/example_test.go 21
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):
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
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
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
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
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 useswinter_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: widgetML controls — field types belong in cabana (formFieldTypes). HasSettingsfor Locales — it is CRUD; useHasAdminControllers+HasNavigation.HasHouseMiddlewareas the locale seam — house-MW is named;locale()is hardcoded inwrap.- Messages admin, locale picker, AI/theme commands, message import — deferred.
- Seeding
de— D-08 isen+plonly. - Editing
wn-translate-plugin/ PHP tree. - AutoMigrate as schema — gormigrate squash only.
- Hand-editing
boardwalk/distor 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