Files
summercms/.planning/phases/14.2.1-translate-plugin/14.2.1-PATTERNS.md
2026-10-06 11:22:45 +02:00

38 KiB
Raw Blame History

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

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

  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

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. 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

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.

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