# Phase 15: Journal plugin - Pattern Map **Mapped:** 2026-10-06 **Files analyzed:** 54 new or modified files (plugin + host + tests); 0 framework field-type files **Analogs found:** 52 / 54 Path roots: - `FW/` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` (working directory; relative paths below are from here unless prefixed). - `TR/` = `../sm-translate-plugin/` — closest compiled-plugin analog (sibling checkout, `Requires` empty, `Translatable`, host already mounts it). - `USR/` = `../fonoteka.go/plugins/golem15/user/` — the `sm-user-plugin` submodule. Closest analog for `HasRoutes`, `Buckets`, CLI commands, YAML with filters/relations/fileupload, `ListExtendQuery`, CORS test. `/media/nvme/dev/golem15/fonoteka.go/plugins/golem15/user/` does **not** exist; this path does. - `APP/` = `../sm-grzybyfunkcjonalne-app/` — proof host from 14.2.1 (user + translate only today). - `PHP/` = `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal` at SHA `02110eb1c0c3861370b0b9b47b209a0702ac5d88` (verified `git rev-parse HEAD` this session). Read-only contract. Do not edit (D-07). - `JR/` = `../sm-journal-plugin/` — **does not exist yet** (D-23). Plan 01 clones `git@git.golem15.com:golem15/sm-journal-plugin.git` there. All analog paths below are git-tracked (`git ls-files` in `summercms.go`, inside `sm-translate-plugin`, inside the user submodule, inside `sm-grzybyfunkcjonalne-app`, and inside the PHP journal plugin). No gitignored mirror is named. `modules/boardwalk/dist`, `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are generated outputs: this phase does **not** regenerate them (D-11 already shipped). **D-11 no-op:** `type: markdown` / `mltext` / `mlmarkdown` already live in `FW/modules/cabana/form_schema.go` lines 24-29 and `docs/backend/forms.md` 324-346. Do not add a second YAML type. Do not change `cabana.RenderMarkdown`. Journal post `content` is `mlmarkdown`. Plugin `FormatHTML` owns footnotes/tables/attributes + the same rejectUnsafe gate. **Replace paths (locked RESEARCH pitfall 9):** sibling plugin `go.mod` uses `replace git.golem15.com/golem15/summercms => ../summercms.go` (`TR/go.mod` line 122). Host uses `replace …sm-journal-plugin => ./plugins/golem15/journal`. Do **not** copy `USR/go.mod`'s `../../../../summercms.go` into the sibling checkout. **Tables (locked):** squash to `golem15_journal_*`. Do not emit `rainlab_journal_*`. Settings is a dedicated singleton table ID=1, not PHP `system_settings`. Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): **15-01** clone plugin + squashed schema + models + Translatable + host submodule/`summer.yaml`/`go.work`/`http.yaml` CORS + boot_test 3 plugins; **15-02** admin controllers, adapted YAML, settings, permissions, nav SVG, FormatHTML, import/export CLI + toolbar; **15-03** `/_journal/api/v1`, buckets, optional editor on GET, backend Bearer writes, media upload, Typesense gate; **15-04** unit/integration tests last. ## File Classification ### New plugin (`JR/` = `sm-journal-plugin`) | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `JR/go.mod` | config | — | `TR/go.mod` (sibling replace `../summercms.go`) | exact | | `JR/plugin.go` | provider | request-response | `TR/plugin.go` + `USR/plugin.go` (`HasRoutes`/`Buckets`/`HasCommands`) | exact | | `JR/README.md` | docs | — | `TR/README.md` (neutral names; never name the host) | exact | | `JR/admin.go` | provider | — | `TR/admin.go` + `USR/admin.go` (file-by-file embed) | exact | | `JR/admin_permissions.go` | config | — | `USR/admin_permissions.go` + `PHP/Plugin.php` 55-90 | exact | | `JR/admin_navigation.go` | config | — | `USR/admin_navigation.go` + `PHP/Plugin.php` 96-134; icon alias `FW/admin/src/app/icons.ts` 273 | exact | | `JR/lang/en/lang.yaml`, `JR/lang/pl/lang.yaml` | config | transform | `TR/lang/{en,pl}/lang.yaml` + `PHP/lang/en/lang.php` keys `plugin.*` / `journal.*` / `post.*` | exact | | `JR/assets/images/journal-icon.svg` | config | file-I/O | `PHP/assets/images/journal-icon.svg` (embed bytes; D-08) | exact | | `JR/models/registry.go` | utility | — | `TR/models/registry.go` | exact | | `JR/models/post.go` | model | CRUD | `USR/models/user.go` (Fillable/MorphName/AttachRelations/FilterScope) + `PHP/models/Post.php` (translatable, canEdit, formatHtml, Searchable) | exact | | `JR/models/category.go` | model | CRUD | `USR/models/user_group.go` + `PHP/models/Category.php` fillable 45-51 | exact | | `JR/models/tag.go` | model | CRUD | `USR/models/user_group.go` + `PHP/models/Tag.php` fillable 27-31 | exact | | `JR/models/settings.go` | model | CRUD | `FW/modules/cabana/example_controller_test.go` `BlogSettings` 58-72 | exact | | `JR/updates/registry.go` | utility | — | `TR/updates/registry.go` | exact | | `JR/updates/202610060001_create_golem15_journal_posts.go` | migration | CRUD | `TR/updates/202610060001_create_golem15_translate_locales.go` | exact | | `JR/updates/202610060002_create_golem15_journal_categories.go` | migration | CRUD | same CREATE pattern | exact | | `JR/updates/202610060003_create_golem15_journal_tags.go` | migration | CRUD | same CREATE pattern | exact | | `JR/updates/202610060004_create_golem15_journal_posts_categories.go` | migration | CRUD | same CREATE pattern (pivot) | exact | | `JR/updates/202610060005_create_golem15_journal_posts_tags.go` | migration | CRUD | same CREATE pattern (pivot) | exact | | `JR/updates/202610060006_create_golem15_journal_settings.go` | migration | CRUD | same CREATE pattern (singleton ID=1) | exact | | `JR/updates/202610060007_add_author_slug_to_backend_users.go` | migration | CRUD | `USR/updates/202609220005_extend_users.go` (ALTER); use `IF NOT EXISTS` | role-match | | `JR/classes/format_html.go` | service | transform | `FW/modules/cabana/field_markdown.go` rejectUnsafe + `PHP/models/Post.php` `formatHtml` 199-225 | partial | | `JR/controllers/admin_registry.go` | config | — | `TR/controllers/admin_registry.go` + `USR/controllers/admin_registry.go` | exact | | `JR/controllers/posts.go` | controller | CRUD | `USR/controllers/users_admin_controller.go` (ListExtendQuery, FormBeforeCreate, permissions) | exact | | `JR/controllers/categories.go` | controller | CRUD | `TR/controllers/locales.go` | exact | | `JR/controllers/tags.go` | controller | CRUD | `TR/controllers/locales.go` | exact | | `JR/controllers/posts/{config_list,config_form,config_filter}.yaml` | config | file-I/O | `USR/controllers/users/*.yaml` + `PHP/controllers/posts/*` (adapt, do not byte-copy) | exact | | `JR/controllers/categories/{config_list,config_form}.yaml` | config | file-I/O | `TR/controllers/locales/*.yaml` + `USR/controllers/usergroups/*.yaml` | exact | | `JR/controllers/tags/{config_list,config_form}.yaml` | config | file-I/O | same | exact | | `JR/models/post/{fields,columns}.yaml` | config | file-I/O | `USR/models/user/fields.yaml` (relation/fileupload) + `FW/docs/backend/forms.md` 332-346 (`mltext`/`mlmarkdown`) | exact | | `JR/models/category/{fields,columns}.yaml` | config | file-I/O | `TR/models/locale/fields.yaml` + `PHP/models/category/*` | exact | | `JR/models/tag/{fields,columns}.yaml` | config | file-I/O | same | exact | | `JR/models/settings/fields.yaml` | config | file-I/O | `PHP/models/settings/fields.yaml` minus `trigger`/`placeholder` | exact | | `JR/routes.go` | route | request-response | `USR/routes.go` + `PHP/routes.php` | exact | | `JR/controllers/api/posts.go` | controller | request-response | `USR/controllers/api_controller.go` (flat `{error}` JSON) + `PHP/controllers/api/PostApiController.php` | exact | | `JR/controllers/api/media.go` | controller | file-I/O | `PHP/controllers/api/MediaApiController.php` + `USR` avatar upload (blob, mime, size) | exact | | `JR/console/export_posts.go`, `JR/console/import_posts.go` | utility | batch | `USR/console/require_password_change.go` + `PHP/console/ExportPosts.php` | exact | | `JR/search.go` (Gate on Post, or methods on `post.go`) | service | CRUD | `FW/modules/beachcomber/searchable.go` + `example_test.go` `installGate` 123-133 | exact | ### Proof host (`APP/` = `sm-grzybyfunkcjonalne-app`) | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `APP/summer.yaml` | config | — | same file today (add journal) | exact | | `APP/go.work` | config | — | same file (add `./plugins/golem15/journal`) | exact | | `APP/go.mod` | config | — | same file replace block 85-87 | exact | | `APP/.gitmodules` | config | — | same file (add journal submodule) | exact | | `APP/config/http.yaml` | config | request-response | same file CORS paths; add `_journal/api/*` | exact | | `APP/boot_test.go` | test | request-response | same file `TestBootUserTranslate` (become 3 plugins) | exact | | `APP/plugins.gen.go`, `APP/main.go` | route | — | same files (`summer build`; do not hand-author after first boot) | exact | ### Tests (plan 04 last) | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `JR/admin_harness_test.go` + Posts/Categories/Tags admin tests | test | request-response | `TR/admin_harness_test.go` `newAdminEnv` 172-182 | exact | | `JR/updates/postgres_test.go` | test | CRUD | `TR/updates/postgres_test.go` TestMain + testcontainers | exact | | `JR/controllers/api/*_test.go` JOURNAL-005/006, 401 shape, per_page 9/30 | test | request-response | PHP `tests/security/` + `USR/register_test.go` `TestRegisterCORSPath` 263-271 | exact | | `JR/models/*_test.go` Fillable / Translatable / MorphName | test | CRUD | `USR/models/admin_models_test.go` FilterScopes | exact | | `APP/boot_test.go` (3 plugins + CORS + controller IDs) | test | — | same file (modify) | exact | ### Framework (`FW/`) — do not create | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | cabana markdown / mlmarkdown | — | — | already shipped Phase 14.2.1 | n/a (no-op) | ## Pattern Assignments ### `JR/go.mod` (config) — plan 01 **Analog:** `TR/go.mod` lines 1-14, 122 ``` module git.golem15.com/golem15/sm-translate-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-journal-plugin`, Go 1.27.0, require GORM + gormigrate + summercms, **identical** sibling replace `../summercms.go`. Do not add goldmark unless `FormatHTML` lives in the plugin and imports it (then pin `github.com/yuin/goldmark v1.8.6`, already `FW/go.mod` line 30). Do **not** require `sm-user-plugin` (redactor_id is a naked integer; RESEARCH pitfall 7 / anti-pattern). `TR/go.mod` currently requires user for admin harness only — Journal must not copy that unless the last-plan harness truly needs it; host tests may join `users`. Decision note: `.planning/notes/core-plugins-own-repos.md` — module path equals repo path; package `journal`; plugin ID `golem15.journal`. README never names a consuming app. --- ### `JR/plugin.go` (provider) — plan 01 / 03 **Analog (compiled init):** `TR/plugin.go` lines 19-68 ```go var ( _ party.Plugin = (*Plugin)(nil) _ pact.HasConfig = (*Plugin)(nil) _ pact.HasMigrations = (*Plugin)(nil) _ pact.HasModels = (*Plugin)(nil) _ pact.HasLang = (*Plugin)(nil) ) func (p *Plugin) ID() string { return "golem15.translate" } func (p *Plugin) Requires() []string { return nil } func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil } func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } func (p *Plugin) Models() []any { return models.All() } func init() { party.Register(&Plugin{}) } ``` Journal: `ID() "golem15.journal"`, **`Requires() []string{"golem15.translate"}`**. PHP `$require` also names Apparatus — Apparatus is dissolved; do **not** require an apparatus plugin (`PHP/Plugin.php` 15-18 vs RESEARCH §2). **Analog (routes / buckets / commands):** `USR/plugin.go` lines 28-40, 145-166, 203-204 and `USR/routes.go` 9-31 ```go _ pact.HasRoutes = (*Plugin)(nil) _ pact.HasCommands = (*Plugin)(nil) _ surf.BucketProvider = (*Plugin)(nil) func (p *Plugin) Buckets() map[string]surf.Bucket { trusted := surf.TrustedProxies(p.app.Config) return map[string]surf.Bucket{ "user-api": { Max: 120, Decay: time.Minute, Key: func(r *http.Request) string { if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil { return "u:" + sub } return surf.ClientIP(r, trusted) }, }, } } ``` Copy two buckets named **`journal-public-api`** and **`journal-api`**, Max 120, Decay `time.Minute` (`PHP/routes.php` 7-14; `FW/modules/surf/limiter.go` 17-33). Public key is IP only. Write-bucket key is `u:` + backend principal ID when `bouncer.User(r.Context())` is a backend principal, else IP. 429 body stays surf's `{"message":"Too Many Attempts."}` (`limiter.go` 17) — do not invent a Journal 429 shape. Do **not** copy user JWT/bouncer/mail from `USR/plugin.go` Boot. Do **not** register `"backend"` middleware onto public GET (pitfall 4). Admin capability assertions live in `admin.go`. Also `_ pact.HasSettings`. **PHP contract (do not port):** `registerComponents`, Winter.Pages menu types, dashboard report widget, Scout service-provider registration as a required dep. Port Scout only as `beachcomber.Searchable` behind a Gate that defaults off. --- ### `JR/admin.go` + permissions + navigation — plan 02 **Analog:** `TR/admin.go` 12-34, `USR/admin.go` 12-38, `USR/admin_permissions.go` 14-21, `USR/admin_navigation.go` 11-43 ```go //go:embed controllers/locales/config_list.yaml controllers/locales/config_form.yaml models/locale/fields.yaml models/locale/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 }) } ``` Embed YAML **file-by-file** (controllers/ also holds `.go`). Include settings `models/settings/fields.yaml` and the SVG if AdminFS is the right tree; otherwise `//go:embed assets/images/journal-icon.svg` on the plugin for nav. Do not embed the whole `controllers/` directory. **Permissions** — copy codes exactly from `PHP/Plugin.php` 55-90: - `golem15.journal.manage_settings` - `golem15.journal.access_posts` - `golem15.journal.access_categories` - `golem15.journal.access_other_posts` - `golem15.journal.access_import_export` - `golem15.journal.access_publish` - `golem15.journal.access_tags` Tab/label phrase keys from `PHP/lang/en/lang.php` 8-24. `Roles: []string{"developer"}` like user. `cabana.Allows` is Winter `hasAnyAccess` (OR) (`FW/modules/cabana/contracts.go` 143-161). **Navigation analog:** main item `Code: "journal"`, `Permissions: []string{"golem15.journal.*"}`, `Order: 300`, `Controller: "golem15.journal.posts"`. PHP `icon-pencil` already maps to lucide `pencil` (`FW/admin/src/app/icons.ts` 273) — use `"pencil"` or `"icon-pencil"`; do not invent a pack. Side menu: `new_post` (create URL → controller create), `posts`, `categories`, `tags` with the PHP permission lists. Embed `PHP/assets/images/journal-icon.svg` (D-08). Skip dashboard widget. **Settings analog:** `FW/modules/cabana/example_controller_test.go` 120-132 and `docs/backend/settings.md` 7-29 ```go func (p *BlogPlugin) Settings() []pact.SettingsItem { return []pact.SettingsItem{{ Code: "blog", Label: "acme.blog::lang.settings.label", Icon: "icon-pencil", Permissions: []string{"acme.blog.access_settings"}, Form: "models/settings/fields.yaml", NewModel: func() any { return &BlogSettings{} }, }} } ``` Journal: `Code: "journal"`, permissions `golem15.journal.manage_settings`, `Form: "models/settings/fields.yaml"`, `NewModel: func() any { return &models.Settings{} }`. Dedicated table `golem15_journal_settings` ID=1 — not `system_settings` (`PHP/models/Settings.php` 13 vs cabana docs). --- ### `JR/models/post.go` (model, CRUD) — plan 01/02 **Analog (Go struct + Fillable + MorphName + Attach):** `USR/models/user.go` 57-71, 75-80 ```go func (User) TableName() string { return "users" } func (User) MorphName() string { return `Golem15\User\Models\User` } func (User) AttachRelations() []attach.Relation { return []attach.Relation{{Name: AvatarField, Public: true}} } func (User) Fillable() []string { return []string{ "name", "surname", "email", ... } } ``` **Contract:** `PHP/models/Post.php` 37, 48-65, 102-128, 144-197 - `TableName() "golem15_journal_posts"` - `MorphName() \`Golem15\Journal\Models\Post\`` — never `journal.Post` or `reflect.TypeOf` (pitfall 10) - `Translatable()`: `title`, `content`, `content_html`, `excerpt`, `metadata` - `TranslatableIndexes()`: `slug` - Admin Fillable: form columns only. **Never** fill `redactor_id`, `content_html`, `user_id` from the public API body. `user_id` stamped from backend principal on create (`PHP` beforeSave 145-150). - Rules: `title` required; `slug` required + regex + unique; `content` required; published+published_at together (PHP `afterValidate` 181-187) as `FormBeforeCreate`/`FormBeforeUpdate` returning `&cabana.ValidationError` - Relations: `user` belongsTo `cabana.BackendUser`; `categories`/`tags` belongsToMany via `golem15_journal_posts_categories` / `_tags`; `featured_images`/`content_images` attachMany (`system_files`, morph PHP class string) - `canEdit`: owner or `golem15.journal.access_other_posts` (194-197) - `FilterScopes`: `FilterPublished`, `FilterCategories` (and daterange uses `column: created_at` — no `conditions` key) **FilterScope analog:** `USR/models/user.go` 99-117 ```go func (User) FilterScopes() []string { return []string{FilterByGroup} } func (User) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if name != FilterByGroup { return db.Where("1 = 0") } ... } ``` PHP `config_filter.yaml` `conditions:` **boot-fails** in cabana (`FW/modules/cabana/filter_schema.go` 17-20). Re-express: - `published` switch → `FilterPublished` applying `published <> true` / `published = true` - `published_date` daterange → YAML `type: daterange` `column: created_at` (legal keys only) - `category` `scope: FilterCategories` — include child categories as PHP does **Searchable analog:** `FW/modules/beachcomber/searchable.go` 12-22 and `example_test.go` 31-34, 123-133 ```go func (Post) SearchableAs() string { return "acme_blog_posts" } func (p *Post) ShouldBeSearchable() bool { return p.Published } svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool { var enabled bool err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error return err == nil && enabled })) ``` Journal: `SearchableAs() "golem15_journal_posts"`. Gate reads `golem15_journal_settings.search_use_typesense` for ID=1; read errors count as off. `ShouldBeSearchable` false when unpublished **or** gate off. Fresh install never contacts Typesense. --- ### `JR/models/category.go` / `tag.go` (model, CRUD) — plan 01 **Analog:** `USR/models/user_group.go` 24-46 + PHP fillable Category (`PHP/models/Category.php` 21, 27-40, 45-51): `TableName "golem15_journal_categories"`, Fillable **only** `name`, `slug`, `code`, `description`, `parent_id`. **Not** `nest_left`/`nest_right`/`nest_depth` (JOURNAL-002). Translatable `name`, `description`; indexes `slug`. `MorphName() \`Golem15\Journal\Models\Category\``. Keep nest_* columns in DDL; no NestedTree reorder UI; `parent_id` is a `relation`. Tag (`PHP/models/Tag.php` 14, 19-31): `TableName "golem15_journal_tags"`, Fillable **only** `name`, `slug`, `description`. **Not translatable.** Rules `name` required; `slug` required|between:3,64|unique. --- ### `JR/models/settings.go` (model, CRUD) — plan 02 **Analog:** `FW/modules/cabana/example_controller_test.go` 58-72 + `FW/modules/cabana/settings.go` 19-26, 46-51 (Fillable + Rules required at boot) PHP `$rules` (`PHP/models/Settings.php` 17-31): `show_all_posts`, `use_rich_editor`, `search_use_typesense`, weights, RSS fields. Defaults: show_all_posts true, use_rich_editor false, search_use_typesense false, weights 5/3/1. `use_rich_editor` stored and **ignored** at compile time (always `mlmarkdown`). Settings YAML: drop every `trigger` block; always show weight fields; drop `placeholder`. --- ### `JR/updates/*` (migration, CRUD) — plan 01 **Analog (CREATE + Register):** `TR/updates/202610060001_create_golem15_translate_locales.go` 8-38 and `TR/updates/registry.go` 7-15 ```go ID: "202610060001_create_golem15_translate_locales", Migrate: func(tx *gorm.DB) error { for _, stmt := range []string{`CREATE TABLE golem15_translate_locales (... )`, `CREATE INDEX ...`} { if err := tx.Exec(stmt).Error; err != nil { return err } } return nil }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS golem15_translate_locales`).Error }, ``` **Analog (ALTER another plugin's table):** `USR/updates/202609220005_extend_users.go` 10-31 — Journal's author_slug uses `ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`. Do **not** put this in lagoon's framework migration. `BackendUser.TableName()` is `"backend_users"` (`FW/modules/cabana/contracts.go` 56). Squash IDs (planner may adjust date prefix, not table names): | ID | Table / change | |----|----------------| | `202610060001_create_golem15_journal_posts` | posts columns from RESEARCH §1 (incl. content_html, metadata JSONB, sources JSONB, is_pinned, redactor_id, unique slug) | | `202610060002_create_golem15_journal_categories` | + nest_* + unique slug | | `202610060003_create_golem15_journal_tags` | unique slug | | `202610060004_create_golem15_journal_posts_categories` | pivot | | `202610060005_create_golem15_journal_posts_tags` | pivot | | `202610060006_create_golem15_journal_settings` | typed singleton columns | | `202610060007_add_author_slug_to_backend_users` | ALTER | Do not emit `rainlab_journal_*`. Seed optional Uncategorized only if PHP seeder at this pin still inserts it (assumption A4). AutoMigrate is never the schema source. --- ### `JR/classes/format_html.go` (service, transform) — plan 02 **No plugin analog.** Combine: 1. **Contract:** `PHP/models/Post.php` 199-225 — footnotes + attributes + tables, then Html::clean unless `backend.allow_unsafe_markdown`. Ignore `use_rich_editor` (deferred WYSIWYG). 2. **Reject gate analog:** `FW/modules/cabana/field_markdown.go` 11-46 — goldmark **without** `html.WithUnsafe`; reject script/iframe/event handlers/dangerous URLs. ```go markdownEngine = goldmark.New() func RenderMarkdown(src string) (string, error) { ... if err := rejectUnsafeMarkdownHTML(html); err != nil { return "", err } return html, nil } ``` **Do not change `cabana.RenderMarkdown` globally** (pitfall 15; mail-aligned). Put `journal.FormatHTML` in the plugin: same `github.com/yuin/goldmark` module with footnote/table/attribute extensions (assumption A1 — if a separate module is required, stop). Stored `content_html` and public API use FormatHTML. SPA preview may use `cabana.RenderMarkdown`. Call FormatHTML from admin save hooks and API writes, not only API. --- ### Admin controllers (controller, CRUD) — plan 02 **Analog:** `USR/controllers/users_admin_controller.go` 40-96, 243-258 + `TR/controllers/locales.go` 14-33 + `USR/controllers/admin_registry.go` 12-36 ```go func (usersAdminController) ID() string { return "golem15.user.users" } func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` } func (usersAdminController) ConfigDir() string { return "controllers/users" } func (usersAdminController) RequiredPermissions() []string { return []string{PermissionAccessUsers} } func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Unscoped() } ``` | PHP | Go ID | Perm | ModelName | |-----|-------|------|-----------| | Posts.php | `golem15.journal.posts` | `access_posts` | `Golem15\Journal\Models\Post` | | Categories.php | `golem15.journal.categories` | `access_categories` | `Golem15\Journal\Models\Category` | | Tags.php | `golem15.journal.tags` | `access_tags` | `Golem15\Journal\Models\Tag` | PHP Posts `$requiredPermissions` is OR of `access_other_posts` and `access_posts` (`PHP/controllers/Posts.php` 21). Cabana `Allows` is OR — `RequiredPermissions: []string{"golem15.journal.access_posts"}` for the screen; **additionally** restrict query without `access_other_posts`. **ListExtendQuery / FormExtendQuery analog:** `PHP/controllers/Posts.php` 60-72 — without `access_other_posts`, `where user_id = principal.ID`. Principal from `bouncer.User(ctx)` (`FW/modules/bouncer/context.go` 32-38). **FormBeforeCreate analog:** stamp `user_id` from backend principal if empty (`PHP/models/Post.php` 145-150). Pattern of hook shape: `USR/controllers/users_admin_controller.go` 243-258 (stamp fields, return `ValidationError`/`ForbiddenError`). Also regenerate `content_html` via FormatHTML on create/update. **Publish without permission:** return `&cabana.ForbiddenError{Message: ...}` (`FW/modules/cabana/crud.go` 71-84) on publish writes without `golem15.journal.access_publish`. Hiding fields is UX; refuse is mandatory (`PHP/models/Post.php` filterFields 164-178). **Import/export toolbar:** no cabana ImportExport behavior. `pact.HasAdminActions` (`FW/modules/pact/capabilities.go` 216-228, 266-269) with `Permissions: []string{"golem15.journal.access_import_export"}`. CLI names `journal:export-posts` / `journal:import-posts` (`PHP/Plugin.php` 169-170). Command analog: `USR/console/require_password_change.go` 16-25 (`bonfire.Command{Name, Description, Args, Run}`). Port PHP `PostImport`/`PostExport` column sets; do not invent a generic CSV framework. Error types: `FW/modules/cabana/crud.go` 65-84 `ValidationError` (422) / `ForbiddenError` (403). --- ### Admin YAML (config, file-I/O) — plan 02 **Go analog (list/form):** `USR/controllers/users/config_list.yaml` 1-16, `config_form.yaml` 1-17, `config_filter.yaml` 1-14 (legal keys only: `label`, `type`, `column`, `modelClass`, `nameFrom`, `scope`). `TR/controllers/locales/config_list.yaml` `recordUrl` / `defaultSort`. **Go analog (fields):** `USR/models/user/fields.yaml` 38-57 (`type: relation`, `type: fileupload` `mode: image`) and `FW/docs/backend/forms.md` 332-346: ```yaml fields: title: type: mltext body: type: mlmarkdown size: huge ``` **PHP contract to rewrite, not copy:** `PHP/models/post/fields.yaml` uses boot-fail keys (`placeholder`, `cssClass`, `stretch`, `commentAbove`, `trigger`, `type: taglist`, `type: repeater`, widget class, `tabs.stretch`). Cabana allow-list: `FW/modules/cabana/form_schema.go` 24-47. | PHP field | Go YAML | |-----------|---------| | `title` | `type: mltext` | | `slug` | `type: mltext` + `preset` field title type slug | | `content` | `type: mlmarkdown` — not JournalMarkdown widget | | `excerpt` | `type: mltext` | | `categories` | `type: relation` `nameFrom: name` | | `tags` | `type: relation` `nameFrom: name` — **not** `taglist`; create tags on Tags admin | | `published` | `type: switch` or `checkbox` | | `is_pinned` | `type: checkbox` | | `user` | `type: relation` `nameFrom: login` `emptyOption` current user | | `published_at` | `type: datepicker` `mode: datetime`. Drop `trigger`. | | `featured_images` | `type: fileupload` `mode: image` `imageWidth`/`imageHeight` 200 | | `sources` repeater | **Omit from admin form.** Keep JSONB; write API still accepts `sources`. | | `metadata[preview_page]` | **Omit** (Phase 16). Keep `metadata` JSONB. | | `toolbar` partial | Omit | List: `type: date` is legal (`FW/modules/cabana/list_schema.go` 22-24). Unpublished row class → `pact.RowStateDisabled` if wired (`USR` ListRowStates 98-125); else skip. `config_list.yaml` `recordUrl: golem15/journal/posts/update/:id` like users/locales. PHP `recordsPerPage: 25` (`PHP/controllers/posts/config_list.yaml` 21). Filters: **no `conditions:`**. Analog `USR/controllers/users/config_filter.yaml`: ```yaml scopes: groups: type: (omit — modelClass + scope) modelClass: Golem15\User\Models\UserGroup nameFrom: name scope: filterByGroup created_date: type: daterange column: created_at activated: type: switch column: is_activated ``` --- ### `JR/routes.go` + API controllers (controller, request-response) — plan 03 **Analog (group + throttle + Where):** `USR/routes.go` 9-31 and `FW/modules/surf/example_test.go` 75-88 ```go func (p *Plugin) Routes(r pact.Router) error { r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { g.Get("/fetch", controllers.Fetch(p.app)) g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth") g.Where("id", "[0-9]+") }) return nil } ``` **Contract:** `PHP/routes.php` 16-67. Two groups, same prefix `/_journal/api/v1`: 1. Public: `throttle:journal-public-api` **only**. GET `posts`, `posts/{slug}` with `Where("slug", `[a-z0-9][a-z0-9\-/]*`)`, `categories`, `tags`, `rss`. 2. Writes: required backend principal + `throttle:journal-api`. POST/PUT/DELETE posts, featured-images, media/upload. `Where("id", `[0-9]+`)`. Do **not** attach cabana `"backend"` middleware (writes `WriteError` 401 `unauthenticated` / `"Unauthenticated"` — `FW/modules/cabana/http.go` 200-201, `contracts.go` 215-231). PHP shape is flat `{"error":"..."}`. **Flat JSON analog:** `USR/controllers/api_tokens.go` 96 (`writeJSON(w, 404, map[string]any{"error": "Token not found"})`) and `USR/controllers/api_controller.go` 1025-1028 (`wire.WriteJSON`). **Not** `cabana.WriteError`. **PHP error strings** (`PHP/controllers/api/PostApiController.php` 237, 263, 268; MediaApi 26-41; show 204-216): - 401 `{"error":"Authentication required"}` - 403 `{"error":"Insufficient permissions"}` / `{"error":"You do not have permission to publish posts"}` - 404 `{"error":"Post not found"}` — **no `data` key** on drafts (JOURNAL-005) - 422 `{"error":"Validation failed","errors":{...}}` **Write auth:** verify cabana backend JWT (HS256, audience `backend`, blacklist `backend_jwt_blacklist`). Analog: `FW/modules/bouncer/jwt.go` 90-104 `NewBackendJWTGuard` + `FW/modules/cabana/http.go` 137-153 (cabana registers `"backend"`). Journal write handlers Lookup `*bouncer.Registry`, Authenticate the `"backend"` guard **in-handler** (or a plugin middleware that writes PHP JSON, not cabana's `writeUnauthenticated`). D-15: backend Bearer only — not `user.api_token`, not `g15_`. **Optional editor on public GET:** `PHP/controllers/api/PostApiController.php` `isEditor` 685-688. Do **not** attach `"backend"` (missing token would 401). If `Authorization: Bearer` is present, verify with the same backend JWT and `bouncer.WithUser`; otherwise anonymous. Analog pieces: `bouncer.Registry.Middleware` 67-101 (on failure **without** UnauthorizedWriter, next runs unauthenticated — but cabana's guard **does** implement UnauthorizedWriter, so attaching it 401s). Therefore: call `Guard.Authenticate` only when the header is present; on failure treat as anonymous for GET (do not write 401 on public GET). `bouncer.User(ctx)` (`context.go` 32-38) afterwards. `cabana.Allows(pr, []string{"golem15.journal.access_posts"})`. **Draft visibility (JOURNAL-005):** unpublished **or** `published_at` in the future. Show 404 no `data` unless owner or `access_other_posts` (`PHP` 207-217, `canViewDraft` 696-708). Index: anonymous `published=true`; editor default includes drafts unless `?published=true`. `per_page` default **9** max **30** (`PHP` 34) — not API.md 15/50. **Serialize:** do not apply `UNPUBLISHED_TITLE_PREFIX` on API JSON. Include `previous_post`, `next_post`, `related_posts` on show. Translations object on write via translate plugin helpers (`classes.SetTranslated`), not extra columns. Field-by-field assign on store (`PHP` 276-287) — never `lagoon.Fill` the whole body onto Post. **RSS:** ship GET `rss` as XML (`PHP/routes.php` 33-35). No Go analog; stdlib `encoding/xml`. --- ### `JR/controllers/api/media.go` (controller, file-I/O) — plan 03 **Contract:** `PHP/controllers/api/MediaApiController.php` 21-62. Require backend user + `access_posts`; folder regex `^[A-Za-z0-9_\-\/]*$`; force under `journal/`; strip leading `journal`; reject `..`. Image mimes jpg/jpeg/png/gif/webp max 10240 KB. 201 `{data:{url,path}}`. Use gocloud blob + host `upload_bytes` but still enforce 10MB in the handler. Analog blob/size: `USR` avatar (`avatarMaxBytes` in `api_controller.go` 37) — copy the permission/path rules from PHP, not the avatar field. --- ### `JR/console/*` (utility, batch) — plan 02 **Analog:** `USR/console/require_password_change.go` 16-25 + `USR/plugin.go` 203-204 `Commands() []bonfire.Command`. Names from `PHP/Plugin.php` 169-170 and `PHP/console/ExportPosts.php` 11-15 (`journal:export-posts`, `--path`, `--dry-run`). Register via `pact.HasCommands` (`FW/modules/pact/capabilities.go` 15-18). --- ### Phrasebook + README — plan 01/02 **Lang analog:** `TR/lang/en/lang.yaml` — `plugin:` + screen keys. Port `PHP/lang/en/lang.php` and `lang/pl/lang.php` only (D-06). Do not load the other 19 PHP locales. **README analog:** `TR/README.md` 1-15, 30-73 — H1 plugin name, one-sentence summary, import line, Overview/Features/Usage. Neutral `the application`, example `blog` / `acme`. **Never** name grzybyfunkcjonalne or Płytarium (pitfall 13). Document Phase 16 successor for Winter components (`journalPost`, …) in a short out-of-scope note. MorphName example for Journal must be the PHP class string, not the translate README's `Acme\Blog\Models\Post` except as a generic illustration in framework docs. --- ### Proof host (`APP/`) — plan 01 **Analog:** current `APP/summer.yaml` 1-7, `APP/go.work` 5-9, `APP/go.mod` 1-11 and 85-87, `APP/.gitmodules` 1-8, `APP/config/http.yaml` 1-13, `APP/boot_test.go` 17-90, `APP/plugins.gen.go` 1-14 Today: ```yaml plugins: - id: golem15.user module: git.golem15.com/golem15/sm-user-plugin - id: golem15.translate module: git.golem15.com/golem15/sm-translate-plugin ``` Add: ```yaml - id: golem15.journal module: git.golem15.com/golem15/sm-journal-plugin ``` `go.work` `use ./plugins/golem15/journal`. `go.mod` `require` + `replace git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal`. `.gitmodules` path `plugins/golem15/journal` url `git@git.golem15.com:golem15/sm-journal-plugin.git`. CORS add `_journal/api/*`; keep `supports_credentials: false`. `boot_test.go`: `len(plugins) != 2` → 3; allow `golem15.journal`; assert controller IDs `golem15.journal.posts|categories|tags` and that `/_journal/api/v1` routes exist; `assertGitlink` for journal. Today's test **forbids** `golem15.journal` (line 40-42) — change first in 15-01 (pitfall 8). `plugins.gen.go` / `main.go`: regenerate with `summer build`; stamp `// Code generated by summer build. DO NOT EDIT.` Host README may name itself; plugin README must not. --- ### Tests — plan 04 **Admin boot analog:** `TR/admin_harness_test.go` 172-182 `newAdminEnv` — `party.Activate`, `lagoon.Migrate`, mint backend admin with a permission set. Posts 403 without `access_posts`. **Postgres analog:** `TR/updates/postgres_test.go` 25-36 TestMain + testcontainers, `-short` skip. **CORS analog:** `USR/register_test.go` 263-271 `TestRegisterCORSPath` — read host `config/http.yaml`, require `_journal/api/*` (and keep `_user/api/*`). **PHPUnit map:** RESEARCH §8. JOURNAL-005 draft 404; JOURNAL-006 media 403 + path prefix; JOURNAL-001/002 fillable; FormatHTML rejectUnsafe substitutes JOURNAL-003/004 templates (Phase 16). Redactor_id column exists, not Fillable. Also: limiter names, anonymous list hides drafts, write without Bearer 401 PHP shape, publish without `access_publish` 403, YAML compile of adapted fields, `mlmarkdown` save through TranslationWriter, Typesense gate off (zero HTTP). ## Shared Patterns ### Plugin mount (submodule + go.work + replace) **Source:** `TR/go.mod` line 122, `APP/go.mod` 85-87, `APP/go.work` 5-9, `APP/summer.yaml`, `.planning/notes/core-plugins-own-repos.md` **Apply to:** `JR/` repo creation and `APP/` journal mount Module `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, ID `golem15.journal`, `party.Register` in `init`, `Requires` translate. Sibling replace `../summercms.go`. Host replace `./plugins/golem15/journal`. Submodule `plugins/golem15/journal`. ### Admin CRUD + permissions **Source:** `USR/admin.go`, `users_admin_controller.go`, `pact.HasAdminControllers` / `AdminPermissioned` (`FW/modules/pact/capabilities.go` 184-197) **Apply to:** Posts, Categories, Tags YAML + `CRUDService`. `RequiredPermissions`. Fillable allow-list. Lifecycle hooks return `ValidationError` (422) or `ForbiddenError` (403). Fail-loud YAML at `cabana.Activate`. ### Translatable + MorphName **Source:** `TR/classes/translatable.go` 16-24; `USR/models/user.go` 59-60; `TR/README.md` 70-72 **Apply to:** Post, Category Implement `Translatable()`, `TranslatableIndexes()`, `MorphName()` with PHP class strings. Default locale on host columns; other locales via translate plugin. Do not invent `title_pl` columns. ### Public plugin HTTP vs cabana admin envelope **Source:** `USR/routes.go` + `USR/controllers/api_tokens.go` flat `{error}`; `FW/modules/cabana/contracts.go` 215-231 (do **not** use on `/_journal/api/v1`) **Apply to:** all Journal API handlers Cabana SPA admin uses `{error:{code,message,details}}`. Journal API uses PHP `{error: string}`. Two envelopes, one binary. ### Rate limits **Source:** `USR/plugin.go` Buckets 145-166; `FW/modules/surf/limiter.go` 17-33 **Apply to:** `journal-public-api` / `journal-api` Named `surf.BucketProvider`. TrustedProxies + ClientIP. 429 framework shape. ### Backend principal **Source:** `FW/modules/bouncer/context.go` 24-38; `jwt.go` 90-104; `cabana/http.go` 137-153; `cabana/contracts.go` Allows 143-161 **Apply to:** write API (required), public GET (optional), admin ListExtendQuery Writes: authenticate backend JWT, 401 PHP string if missing. Public GET: optional Authenticate when Bearer present; never 401 anonymous. Admin: `bouncer.User` + `cabana.Allows`. ### Search gate **Source:** `FW/modules/beachcomber/searchable.go` Gate 146-163; `example_test.go` installGate **Apply to:** Post Searchable Off by default. Errors count as off. Unpublished not indexed. ### Settings singleton **Source:** `FW/docs/backend/settings.md`; `example_controller_test.go` BlogSettings / Settings() **Apply to:** `golem15_journal_settings` ID=1 `pact.HasSettings`. Fillable + Rules. No `system_settings`. ### Test harness **Source:** `TR/admin_harness_test.go` `newAdminEnv`; `TR/updates/postgres_test.go` **Apply to:** plugin admin + migration + API tests last `party.Activate` + `lagoon.Migrate` + `surf.Assemble`. testcontainers behind `-short`. ### Markdown **Source:** `FW/modules/cabana/field_markdown.go`; `FW/go.mod` goldmark v1.8.6 **Apply to:** admin field type (existing `mlmarkdown`) vs stored HTML (plugin FormatHTML) One library. Two pipelines. Do not merge them. ## No Analog Found | File | Role | Data Flow | Reason | |------|------|-----------|--------| | `JR/classes/format_html.go` (goldmark footnote/table/attribute + rejectUnsafe, not cabana.RenderMarkdown) | service | transform | No plugin currently enables goldmark extensions. Copy PHP `formatHtml` contract and cabana's rejectUnsafe helper; do not edit `field_markdown.go`. | | `JR` RSS XML handler | controller | request-response | No compiled plugin serves RSS. Use stdlib `encoding/xml` and PHP `PostApiController::rss` as the contract. | Planner should use RESEARCH.md §4–§5 for those two. ## Do not copy / anti-patterns - **Byte-copy PHP YAML** — `taglist`, `repeater`, `trigger`, `placeholder`, `conditions` fail boot. - **Attaching `"backend"` middleware to public GET** — anonymous 401 (pitfall 4). - **Cabana `WriteError` envelope on `/_journal/api/v1`** — PHP `{error}` string (pitfall 3). - **`user.api_token` or `g15_`** — D-15. - **Replaying `rainlab_journal_*` table names.** - **Changing `cabana.RenderMarkdown` to enable footnotes globally** (pitfall 15). - **Adding a second markdown YAML type** — D-11 shipped. - **Importing `sm-user-plugin` only to type `redactor`.** - **`replace … => ../../../../summercms.go` in the sibling checkout** — use `../summercms.go` (pitfall 9). - **MorphName `journal.Post`** — PHP class strings (pitfall 10). - **Typesense on by default** (pitfall 11). - **API.md per_page 15/50** — controller 9/30 (pitfall 12). - **Naming the host in the plugin README** (pitfall 13). - **Editing `wn-journal-plugin` / PHP tree** (D-07). - **Adding Journal to the tide 154-route harness** (D-16). - **Porting Translate, Pages menu types, dashboard widgets, or remaining 19 locales.** - **Hand-authoring `plugins.gen.go` after first `summer build`.** - **AutoMigrate as schema.** ## Metadata **Analog search scope:** `TR/` (sm-translate-plugin), `USR/` (sm-user-plugin), `APP/` (sm-grzybyfunkcjonalne-app), `FW/modules/{cabana,pact,surf,bouncer,beachcomber,lagoon}`, `FW/docs/backend`, `FW/admin/src/app/icons.ts`, `PHP/` journal plugin at `02110eb1c0c3861370b0b9b47b209a0702ac5d88` **Files scanned:** ~70 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