From b319b599890f36a57259639b3a12f81742706a71 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 23:17:15 +0200 Subject: [PATCH] docs(16.2): map phase patterns --- .../16.2-PATTERNS.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md diff --git a/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md new file mode 100644 index 0000000..779174c --- /dev/null +++ b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md @@ -0,0 +1,261 @@ +# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Pattern Map + +**Mapped:** 2026-10-09 +**Files analyzed:** 27 (4 repos) +**Analogs found:** 25 / 27 + +Binding post-research decisions: the editor image upload goes through a new cabana-owned route `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` that calls an optional `FormFieldType.Upload`; Journal implements it by reusing its media storage. The phase includes the 16.1-style release (summercms v0.1.17, journal/host bumps, vue-journal-layer v0.2.0 + app pin, UAT redeploy). + +All paths are git-tracked source in their own repos (sm-journal-plugin, vue-journal-layer and vue-grzybyfunkcjonalne-app are sibling repos; there are no `.msd/` mirror paths). + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `modules/pact/capabilities.go` (+FormFieldType, FormFieldValue, FormFieldUpload, FieldError, HasFormFieldTypes) | model/interface | request-response | same file: `AdminAction` / `AdminActionInput` / `HasAdminActions` (lines 205-270) | exact | +| `modules/cabana/field_type.go` (NEW registry, alias/collision, option check, Decode/Load runner, ListValue) | service | transform | `modules/cabana/extension.go` (`compileClientAssets` 253-300, `checkWidgetTag` 320-333, `compileActions` 335+) | exact | +| `modules/cabana/field_type_upload.go` (NEW upload handler) | controller | file-I/O | `modules/cabana/field_file.go` `fileUpload`/`fileUploadOn` (684-760) | exact | +| `modules/cabana/form_schema.go` (formFieldTypes, decodeFields/compileFieldNode take registry) | config/compile | transform | itself (lines 24-30, 466-480) | self | +| `modules/cabana/crud.go` (save: plugin decode/skip in tx; scalarFormField) | service | CRUD | itself: ML path at 600 / 616 / 688 / 709; classifier 1224-1232 | self | +| `modules/cabana/field_ml.go` (mlFieldType registry-aware, hydrate calls Load) | service | CRUD | itself (11-60) | self | +| `modules/cabana/tx_context.go` (+withFieldWrites / FieldWritesFromContext) | utility | request-response | same file `withVirtualFields`/`VirtualFieldsFromContext` (36-60) | exact | +| `modules/cabana/list_schema.go`, `http.go` `projectRow` (plugin column ListValue) | service | transform | `list_schema.go:22-24`, `http.go:1067` | self | +| `modules/cabana/http.go` (Activate threading + route + field-types endpoint) | route | request-response | `http.go:74-121` (Activate), `:335-347` (file routes) | self | +| `modules/cabana/admin_openapi.go` + phase09Routes test list | config | — | existing file-route entries in same file | self | +| `modules/cabana/README.md`, `modules/pact/README.md`, `docs/backend/...` + site nav | docs | — | Phase 10.1 widget sections in same READMEs / `docs/` partials-and-widgets page | role-match | +| `admin/src/components/form/fields/PluginField.vue` (NEW) | component | event-driven | `admin/src/components/form/fields/WidgetField.vue` | exact | +| `admin/src/components/form/fields/MLPluginField.vue` or MLField wiring | component | event-driven | `admin/src/components/form/fields/MLMarkdownField.vue` + `MLField.vue` | exact | +| `admin/src/components/form/registry.ts` | config | — | itself (renderers map, `valueless`, `rendererFor`) | self | +| `admin/src/app/fieldTypes.ts` (NEW global loader) | utility | event-driven | `admin/src/app/pluginAssets.ts` | exact | +| `admin/src/.../mlLocale.ts`, `formState.ts` | utility | transform | itself (`ML_FIELD_TYPES` line 2) | self | +| `admin/src/api/types.ts` generated + `modules/boardwalk/dist` | config | — | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact | +| `../sm-journal-plugin/classes/blog_editor.go` (NEW FormFieldType values) | service | transform | `../sm-journal-plugin/classes/format_html.go` (CheckHTML) + `controllers/categories.go:65-82` | role-match | +| `../sm-journal-plugin/classes/media_store.go` (NEW, extracted) | service | file-I/O | `../sm-journal-plugin/controllers/api/media.go` `UploadMedia` (22-91) | exact (extraction) | +| `../sm-journal-plugin/controllers/posts.go` (`preparePostWrite`, FormAfter* companion) | controller hook | CRUD | itself 204-221 | self | +| `../sm-journal-plugin/controllers/categories.go` | controller hook | CRUD | itself 60-82 (keep) | self | +| `../sm-journal-plugin/plugin.go` / `admin.go` (register types, embed dist) | config | — | `admin.go:16-20` `//go:embed` list | exact | +| `../sm-journal-plugin/models/post/fields.yaml:19`, `models/category/fields.yaml:28` | config | — | — (one-line type swaps) | n/a | +| `../sm-journal-plugin/assets/src/blogeditor/*.ts`, `assets/dist/blogeditor.{js,css}` | component (custom element) | event-driven | element contract from `WidgetField.vue` header; no TipTap analog | **no analog** (use RESEARCH Pattern 4/5) | +| `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh` | config | batch | `admin/package.json`, `scripts/check-admin-dist.sh` | exact | +| `../vue-journal-layer/app/utils/embeds.ts` (NEW) + `PostBody.vue:11`, `CategoryHeader.vue:17` | utility | transform | `../vue-journal-layer/app/utils/htmlGate.ts` | exact | +| Release: tag v0.1.17, journal/host go.mod bumps, layer v0.2.0 + app pin, UAT redeploy | config | batch | Phase 16.1 release plan (`.planning/phases/16.1-*/16.1-02-PLAN.md`) | exact | + +## Pattern Assignments + +### `modules/pact/capabilities.go` (interface) + +**Analog:** same file, `AdminAction` lines 217-270. Copy the shape: a struct of plain fields plus func fields tagged `json:"-"`, a separate `...Input` struct, a separate `...Result` struct, and a one-method `Has...` interface. Doc comments say what the framework owns versus what the func owns. + +```go +type AdminAction struct { + Name string + Label string + Permissions []string + Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"` +} +... +// HasAdminActions is implemented by an admin controller that registers named +// actions for its toolbar.buttons list and its `type: widget` form fields. +type HasAdminActions interface { + AdminActions() []AdminAction +} +``` +`HasFormFieldTypes` is implemented by the **plugin** (not the controller), like `HasAdminControllers` (line 183-184). `pact.FieldError{Messages []string}` replaces `cabana.ValidationError`, because pact cannot import cabana. Asset fields reuse the `AdminClientAssets` path rule documented in lines 205-210 (paths under `assets/`). + +--- + +### `modules/cabana/field_type.go` (registry, boot validation) + +**Analog:** `modules/cabana/extension.go` + +**Element tag rule** (lines 49-55, 320-333). Reuse as is for `FormFieldType.Element`: +```go +func widgetTagPrefix(pluginID string) string { + return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-" +} +func checkWidgetTag(tag, prefix string) error { + if !widgetTagPattern.MatchString(tag) { ... } + if reservedWidgetTags[tag] { ... } + if !strings.HasPrefix(tag, prefix) { ... } + return nil +} +``` + +**Asset read+hash** (lines 253-300). Extract the `read` closure into a function that takes `(pluginID, ownerLabel, fsys, files, exts...)` so controller assets and plugin field-type assets share it. The key is `vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/")` and the version is `digest[:12]`; errors go through `bootErr(pluginID, id, name, err)`. `checkAssetPath` (302-318) is reused unchanged. + +**Name uniqueness pattern**: `compileActions` (335+) builds a map and fails on duplicates or reserved names (`builtinToolbarActions`, line 47). Apply the same check to aliases against the `formFieldTypes` keys, `widget`, `partial` and other plugins' aliases. Do not use a package-level registry: build it in `Activate` (`http.go:74-121`) and pass it into `compileRegistry`. + +--- + +### `modules/cabana/field_type_upload.go` (controller, file-I/O) + +**Analog:** `modules/cabana/field_file.go` lines 684-760, routes in `http.go:335-347`. + +```go +func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) }) +} +... + body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))} + r.Body = io.NopCloser(body) + mr, err := r.MultipartReader() + if err != nil { writeCRUDError(w, invalidBody()); return } + part, err := mr.NextPart() + ... + err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + sc, err := fr.scope(ctx, tx, r) +``` +Route registration follows `g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))`, so the new route is `g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))`. Use `s.uploadBytes` for the cap, load the record through the `FormExtendQuery` scope when `id != 0`, map `pact.FieldError` to 422 on the field, and answer 201 `{data:{url}}`. Also add an entry to `admin_openapi.go` and `phase09Routes` and rerun `scripts/check-admin-openapi.sh`. + +--- + +### `modules/cabana/tx_context.go` (written-values accessor) + +**Analog:** same file, lines 36-60: +```go +type virtualFieldsKey struct{} + +func withVirtualFields(ctx context.Context, values map[string]any) context.Context { + if values == nil { values = map[string]any{} } + return context.WithValue(ctx, virtualFieldsKey{}, values) +} + +func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) { + if ctx == nil { return nil, false } + values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any) +``` +Mirror it as `fieldWritesKey{}` / `withFieldWrites` / exported `FieldWritesFromContext(ctx) (map[string]map[string]string, bool)` (field → locale → value), returning a copy and the same "false outside a create or update save" semantics. + +--- + +### `modules/cabana/crud.go` + `field_ml.go` (save and hydrate) + +**Analog:** the existing ML path in `save()`: +- `crud.go:600` `translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)` runs before the tx. Add the plugin shape check here; translatable plugin types reuse the `liftMLValues` locale-map rules (`field_ml.go` doc at 52-58). +- `crud.go:616` `loadRecord(...)`. Insert the per-(field, locale) Load → compare → skip/Decode step right after this. +- `crud.go:688` `applyMLTranslations(ctx, tx, s.writer, target, translations)`. Decoded non-default values join `translations`. +- `crud.go:709` `hydrateMLRecord(...)`. Call plugin `Load` per locale here. +- `crud.go:1224-1232`: the `scalarFormField` switch gains a registry lookup (it is used by `BindWritableFields` at 204 and `mergedRules` at 1073). +- `field_ml.go:47-53` `mlFieldType(typ)`: becomes registry-aware (a plugin type with `Translatable: true`). +- Error mapping: follow `lifecycleFailure` (crud.go:815-850). `pact.FieldError` becomes a 422; anything else becomes the opaque 500. + +### `modules/cabana/form_schema.go` + +The self-pattern at lines 24-30 (closed set) and 466-480 (key check before type resolution). Reorder so the type resolves first and plugin `Options` keys are checked after it, then call `CheckOptions`. `decodeFields` callers: form_schema.go:194, settings.go:74 (refuse plugin types in v1), relation_form.go:145/:246. + +--- + +### `admin/src/components/form/fields/PluginField.vue` (component, event-driven) + +**Analog:** `admin/src/components/form/fields/WidgetField.vue`. Copy: +- the imports block (lines 1-9: `inject`, `FORM_ASSETS`, `FORM_LOCALE`, `ExtensionFailure`, `FieldControlProps`); +- the imperative element creation in a `mountPoint` that Vue never renders into; +- the `status: 'loading' | 'ready' | 'failed'` ref, the `defined(tag)` wait with `timeout(ms)` (lines 50-60); +- the header-comment rule: never give the element a token, cookie, function or Vue instance. + +Differences: set `el.value` (property, not attribute); listen for `summer-change` → `emit('update:modelValue', detail.value)`; handle `summer-upload` by posting through `api` (the same client as the widget action) to the field upload route, then dispatch `summer-upload-result`. Add event-name constants next to `WIDGET_EVENT`/`WIDGET_RESULT_EVENT` in `formContext.ts:86,93`. + +### ML variant + +**Analog:** `admin/src/components/form/fields/MLMarkdownField.vue` (whole file): +```vue + +``` +Use `:inner="PluginField"`, `inner-type` = the base alias, and `ml-type` = `field.type`. Let `isMLFieldType` (`mlLocale.ts:2`, `ML_FIELD_TYPES`) accept fields flagged `translatable`. + +### `admin/src/components/form/registry.ts` + +Self-pattern: `rendererFor(type)` returns `renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField`. Change it to take the field (or check `field.element`) so plugin types fall back to `PluginField`/`MLPluginField` instead of `UnsupportedField`. `valueless` stays unchanged. Add translatable plugin types to `groupLabelledTypes` behaviour. + +### `admin/src/app/fieldTypes.ts` (global loader) + +**Analog:** `admin/src/app/pluginAssets.ts`. Reuse `assetAllowed()`, the `scripts` once-per-URL map, and the module-script loading. Unlike `activateStyles(controllerId)` (lines 105-110, which disables links whose `owner !== active`), field-type links need a reserved global owner that `activateStyles` skips (Pitfall 8). Call it once after login, from the app shell. + +--- + +### `../sm-journal-plugin/classes/blog_editor.go` (Decode/Load/ListValue/Upload) + +**Analog (gate):** `classes/format_html.go`, `CheckHTML(doc string) error`. **Analog (422 shape and nil content):** `controllers/categories.go:65-82`: +```go +func prepareCategoryWrite(cat *models.Category) error { + if cat.Content == nil { return nil } + if strings.TrimSpace(*cat.Content) == "" { cat.Content = nil; return nil } + if err := classes.CheckHTML(*cat.Content); err != nil { + return &cabana.ValidationError{Details: map[string]any{"content": []string{err.Error()}}} + } + return nil +} +``` +In Decode, return `pact.FieldError{Messages: []string{err.Error()}}` instead of `cabana.ValidationError`. Load serves `content` if it looks like HTML, otherwise `content_html` (non-default locale through `TranslationWriter.TranslatedExact`, `field_ml.go` interface). + +### `../sm-journal-plugin/classes/media_store.go` (extracted storage) + +**Analog:** `controllers/api/media.go` `UploadMedia` lines 22-91. Extract the part after auth (lines ~42-84): the extension allowlist `jpg|jpeg|png|gif|webp`, the `mediaMaxBytes` cap, `mediaObjectPath(folder, name)`, `uniqueMediaKey`, `bucket.NewWriter` and `attach.PublicURL(...)`. Use it as a `StoreMedia(ctx, bucket, folder, filename, body) (url, path string, err error)` called by both `UploadMedia` and the `blogeditor` Upload. Validation messages ("The file must be an image." and so on) become `pact.FieldError` on the field route. The API route keeps its response shape (D-09 parity). + +### `../sm-journal-plugin/controllers/posts.go` + +Self-pattern `preparePostWrite` (204-221). The admin path drops: +```go + html, err := classes.FormatHTML(post.Content) + ... + post.ContentHTML = html +``` +Replace it with `if writes, ok := cabana.FieldWritesFromContext(ctx); ok { if v, ok := writes["content"][defaultLocale]; ok { post.ContentHTML = v } }`. In `FormAfterCreate`/`FormAfterUpdate`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)` for each written non-default locale. `controllers/api/posts_helpers.go` keeps `FormatHTML`. + +### `../sm-journal-plugin/admin.go` / `plugin.go` + +The `//go:embed` list in `admin.go:16-20`, for example `//go:embed assets/images/journal-icon.svg`. Add `assets/dist/blogeditor.js assets/dist/blogeditor.css`. The plugin type implements `pact.HasFormFieldTypes`, with compile-time assertions in the style of `var ( _ pact.AdminController = categoriesAdminController{} )` (categories.go:84+). + +### `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh` + +**Analog:** `scripts/check-admin-dist.sh` (whole file): `set -euo pipefail`, `npm ci` if needed, typecheck, `vite build --outDir "$TMP/dist" --emptyOutDir`, then `diff -r "$TMP/dist" assets/dist` with a clear stale message. Copy the pins from `admin/package.json` (vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3). + +### `../vue-journal-layer/app/utils/embeds.ts` (transform) + +**Analog:** `../vue-journal-layer/app/utils/htmlGate.ts`. Same style: an exported regex constant, a header comment that names the Go source of truth, and pure string functions: +```ts +export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i +export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } { + if (!html) return { safe: true, html: '' } + return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' } +} +``` +`expandEmbeds(html)` runs on `gated.html` after the gate, in `PostBody.vue:11` (`v-html="gated.html"`) and `CategoryHeader.vue:17` (`v-html="content.html"`). + +## Shared Patterns + +### Fail-loud boot errors +**Source:** `modules/cabana/extension.go` `bootErr(pluginID, id, name, err)` and `compileExtension` (60+). **Apply to:** alias collisions, bad element tags, asset paths, and unknown or invalid plugin options. + +### Admin-route guard stack +**Source:** `http.go:335-347` (`requireAjax(...)`) plus `s.protect(w, r, func(cc *CompiledController) {...})` (field_file.go:684). **Apply to:** the field upload route and any field-types boot endpoint. + +### Error to HTTP mapping +**Source:** `crud.go:815-850` `lifecycleFailure`, `writeCRUDError`, `WriteError(w, 500, "error", msgServerError)`. **Apply to:** Decode, Load and Upload failures (`pact.FieldError` becomes a 422; anything else becomes an opaque 500). + +### Element credential rule and event naming +**Source:** `WidgetField.vue` header comment and `formContext.ts:86,93` (`summer-action`/`summer-result`). **Apply to:** PluginField and the blogeditor element (`summer-change`, `summer-upload`, `summer-upload-result`). + +### Committed-dist drift gate +**Source:** `scripts/check-admin-dist.sh`. **Apply to:** the admin SPA (`modules/boardwalk/dist`) and the sm-journal-plugin `assets/dist`. + +### Docs and README rule +**Source:** CLAUDE.md "Documentation". **Apply to:** pact and cabana READMEs and `docs/`, using neutral `blog`/`acme` names. Verify with `go test ./cmd/summer -run TestDocsTree`. + +### Concurrent-session staging +Stage by explicit path. Check `git status` and `git diff` for in-flight edits to `crud.go`, `form_schema.go` and `http.go` before committing. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `../sm-journal-plugin/assets/src/blogeditor/*` (TipTap element, schema, serializer, dirty tracking) | component | event-driven | No TipTap/ProseMirror code in any repo. Use RESEARCH Pattern 3/4/5 and Code Example 3 | +| Server-side "served value equals submitted" skip | service | CRUD | New semantics; there is no existing compare-and-skip in `save()`. Use RESEARCH Pattern 3 | + +## Metadata + +**Analog search scope:** `modules/pact`, `modules/cabana`, `admin/src/{app,components/form}`, `scripts/`, `../sm-journal-plugin/{classes,controllers,admin.go}`, `../vue-journal-layer/app/{utils,components/Journal}` +**Files scanned:** ~25 +**Pattern extraction date:** 2026-10-09