docs(16.2): research phase domain

This commit is contained in:
2026-10-09 23:11:04 +02:00
parent b4adf751c8
commit 99dd072f07

View File

@@ -0,0 +1,767 @@
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Research
**Researched:** 2026-10-09
**Domain:** Go admin framework extension API (pact/cabana), Vue admin SPA, TipTap/ProseMirror custom element, Nuxt layer HTML post-processing
**Confidence:** HIGH for the framework integration points (read from source this session) and for TipTap's lossy behaviour (probed locally against 3.31.4). MEDIUM for the recommended editor-preservation design, which is a design proposal that no tests cover yet.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### Blog Editor: what it is
- **D-01:** The Blog Editor is a **WYSIWYG HTML editor with a source (raw HTML) toggle**, built on ProseMirror through TipTap. It is not a block editor and not a markdown editor.
- **D-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after it passes `classes.CheckHTML`. The API response shape is unchanged; `content` is simply HTML, as the PHP export already carries for imported posts. — **Reversibility:** costly — every post saved from the admin becomes HTML-content. A later move back to markdown would need an HTML-to-markdown migration across live posts.
- **D-03:** **Byte-preserving unless edited.** If the post body is not touched in the editor, the save writes back exactly the stored HTML; the editor must not re-serialize an untouched document. Once the body is edited in WYSIWYG, markup the schema doesn't know (custom classes, divs, attributes) is kept as far as possible through generic node and attribute preservation. The source tab always edits the exact HTML.
- **D-04:** v1 features: basic text (headings, bold/italic, lists, links, blockquote), **images**, **tables**, and **embeds plus a raw HTML block**. The raw HTML block is still subject to `CheckHTML`.
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes `CheckHTML` unchanged. **The XSS gate is not relaxed to allow iframes.** The frontend expands the placeholder into the real iframe (D-17). — **Reversibility:** costly — the placeholder markup becomes stored content in live posts, and the layer's renderer depends on it.
- **D-06:** The editor is **translatable like today's `mlmarkdown`**: a locale switcher over a locale → HTML map, stored through the translate overlay.
#### Markdown vs HTML posts
- **D-07:** **A markdown post opens with its `content_html`**, and the next admin save stores HTML (D-02). This is a one-way migration on first edit, with **no per-post format flag** and no new column.
- **D-08:** **The Blog Editor replaces `mlmarkdown` for Journal post `content`** in `models/post/fields.yaml`. `markdown` and `mlmarkdown` stay cabana core types for other plugins. The `excerpt` field is unchanged.
- **D-09:** **The public write API is unchanged.** `POST`/`PUT /_journal/api/v1/posts` keep the PHP contract: `content` is markdown and the server renders `content_html` with `FormatHTML`. Only the admin form path stores HTML.
- **D-10:** **Admin save gate: `CheckHTML` refuses, nothing is sanitized.** Unsafe HTML returns a 422 with a field error, as category content does today, and nothing is silently rewritten. No sanitizer dependency is added. `CheckHTML` must run on every admin write path for both the default and the non-default locale values. The admin save path must stop running `FormatHTML`/goldmark over HTML content, which is the current data-loss bug (todo `journal-imported-html-admin-resave.md`).
#### Field type contract (framework)
- **D-11:** **Plugins register field types under a short global alias**, for example `type: blogeditor`. Boot fails loudly if two plugins, or a plugin and a cabana core type, claim the same alias. This is closer to Winter's `registerFormWidgets` aliases than to a namespaced key. — **Reversibility:** costly — aliases end up in plugin `fields.yaml` files across repos; renaming means editing every consumer.
- **D-12:** The capability is a new `pact` interface (for example `HasFormFieldTypes`). Its Go side owns four things:
1. **Option validation at boot**: the type's own YAML keys, fail-loud like the built-ins.
2. **Value decode and validation on save**: any JSON shape, with field errors returned as 422.
3. **Load and format for the form**: turns the stored column value into what the element receives. For the Blog Editor, a markdown post is served as its `content_html` (D-07).
4. **List column formatting**: for example, strip tags and truncate.
— **Reversibility:** one-way — a published plugin API in `pact`; later plugins (payment and others, per todo `cabana-plugin-registered-field-types.md`) build on it.
- **D-13:** **The client-side element loads globally on admin boot.** A plugin declares its field-type assets once, and the SPA loads them on every admin page, so the type works in any controller's form, including other plugins' forms. The element binds a value through a documented contract instead of the widget action/fill round-trip. The todo's starting point for that contract is a `value` property plus a `summer-change` event; the planner finalizes it. The element reads only the public `--c-*` CSS variables.
- **D-14:** **Translatable variants are registered by the plugin itself**: the plugin registers both `blogeditor` and `mlblogeditor`, mirroring the `markdown`/`mlmarkdown` pair. The planner decides how a plugin ML type plugs into cabana's existing lift/hydrate path (`field_ml.go`) and the translate adapter, so locale storage is not reimplemented per plugin.
- **D-15:** The form schema and the generated TS types carry plugin types as a generic shape, so the SPA build does not need to know them (per the todo). The cabana README, `docs/`, the admin OpenAPI, the generated TS types and the committed `dist/` are updated in the same change (CLAUDE.md documentation rules).
#### Libraries, media, category content
- **D-16:** **The editor library is TipTap (ProseMirror, MIT).** It is wrapped in a plain custom element and bundled in **sm-journal-plugin's own JS build** (`assets/src` → `assets/dist`), with the bundle committed and embedded, like the admin SPA `dist/`. The host app needs no npm. The admin SPA itself gains no editor dependency.
- **D-17:** **Embeds render in the `vue-journal-layer` blog views.** The layer expands allowlisted embed placeholders into privacy-friendly iframes (for example `youtube-nocookie.com`), so grzybyfunkcjonalne.pl shows them. A new layer tag is pinned in `vue-grzybyfunkcjonalne-app`.
- **D-18:** **Images are uploaded through the existing backend-authenticated Journal media upload route**, by drag-and-drop or file picker. The editor inserts `<img src>` with alt text and an optional caption (`figure`/`figcaption`). There is no media library browser.
- **D-19:** **Journal category content uses the Blog Editor** (`blogeditor`, or `mlblogeditor` if the category content attribute is translatable in the PHP tree) instead of the textarea. **No core cabana `richeditor` type** is added. This closes todo `cabana-richeditor-field-type.md` inside Journal. The existing `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` stays.
### Claude's Discretion
- The exact `pact` interface name and method set, Go identifiers, the element value/event contract details, and the asset declaration shape, all within D-11 to D-15.
- How "untouched" is detected for D-03: client-side dirty tracking versus server-side comparison with the loaded value. Either is acceptable as long as an untouched save is byte-identical.
- The ProseMirror schema strategy for preserving unknown markup (generic passthrough nodes and marks, a global attribute preservation extension).
- The exact embed placeholder markup and the provider allowlist (YouTube at minimum).
- Whether a summercms tag release, the host bump and a UAT redeploy belong in this phase, following the 16.1 D-04 pattern. Recommended, because the goal is to edit imported posts on UAT.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Framework work comes before the Journal work.
- Run the security-review agent: the phase adds a plugin API to `pact`, changes HTML write paths and adds an upload path into the editor.
### Folded Todos
- **`cabana-plugin-registered-field-types.md`**: the framework half of this phase (D-11 to D-15).
- **`cabana-richeditor-field-type.md`**: resolved by reusing the Blog Editor for category content (D-19) instead of a core type.
- **`journal-imported-html-admin-resave.md`**: the data-loss part is fixed by D-02, D-07 and D-10. Its required test applies: re-save an imported HTML post and assert `content` and `content_html` are unchanged. If 16.1 already shipped a stopgap, this phase replaces it.
### Deferred Ideas (OUT OF SCOPE)
- **Core cabana `richeditor`/`mlricheditor` type** for plugins without their own editor. Not needed now; any plugin can register its own type or reuse one.
- **Media library browser** for picking previously uploaded images.
- **HTML input on the public write API** (`/_journal/api/v1/posts`). This would extend the PHP contract; the API stays markdown-in for now.
- **Bulk HTML conversion command** (`journal:content-to-html`). Rejected in favour of convert-on-first-edit (D-07); revisit if a uniform format is ever needed.
- **Allowlisting iframe hosts in `CheckHTML`**. Rejected in favour of placeholders (D-05).
</user_constraints>
<phase_requirements>
## Phase Requirements
No REQUIREMENTS.md IDs are mapped to this phase (ROADMAP: "Requirements: TBD"). Coverage is derived from the CONTEXT decisions and the folded todos. ADMIN-07 (Phase 10.1, complete) is the closest existing requirement; this phase extends it and must not regress it.
| ID | Description | Research Support |
|----|-------------|------------------|
| D-01, D-04 | WYSIWYG + source toggle; text, images, tables, embeds, raw HTML block | Standard Stack (TipTap 3.31.4), Pattern 4 (editor schema), probe results in Pitfalls 1-4 |
| D-02, D-07, D-10 | HTML in both columns; markdown post served as `content_html`; CheckHTML refuses on every locale; admin path stops running goldmark | Pattern 2 (save lifecycle), Pattern 6 (Journal integration), Pitfall 6 |
| D-03 | Byte-identical untouched save | Pattern 3 (two-layer untouched detection), Pitfall 5 |
| D-05, D-17 | Embed placeholder; layer expands it after the gate | Pattern 7 (embed expansion), Code Example 4 |
| D-06, D-14 | ML variant through cabana lift/hydrate + translate adapter | Pattern 2 (`Translatable` flag), Pitfall 7 (non-default `content_html` is never written today) |
| D-08, D-19 | `mlblogeditor` for post content, `blogeditor` for category content | Category content is not translatable (Go model and frozen PHP tree) |
| D-11, D-12, D-13, D-15 | pact field-type API, boot validation, generic schema shape, global asset load | Pattern 1 (registry), Pattern 5 (element contract), Architecture Map |
| D-16 | Plugin-owned JS build, committed dist | Pattern 8 (plugin build), Environment Availability |
| D-18 | Image upload from the editor | **Open Question 1**: the existing route cannot be reached with the admin session cookie; Pattern 9 (cabana-owned field upload route) |
| todo resave | Re-save of untouched imported HTML keeps both columns byte-identical | Validation Architecture (test map) |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
These directives come from `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global `CLAUDE.md`. The planner must honor them like locked decisions:
- **Lean planning.** Use fewer, larger plans. Before writing any PLAN.md, present the suggested plan count with a one-line scope for each and wait for confirmation.
- **Unit tests are the last plan of the phase.** That plan brings full coverage. Earlier plans may carry smoke tests only.
- **Go conventions.** Use the standard library first. Add a Go dependency only when research or a decision names it; this phase adds **no Go dependency**. `go vet` and `go test ./...` must be green at every commit.
- **Compiled plugins only.** No runtime plugin loading. A JS custom element served from the embedded tree is the Phase 10.1 mechanism and is allowed.
- **API parity.** The Journal public API keeps the PHP contract (D-09). Do not "improve" response shapes.
- **Commits.** No co-author tags. One logical change per commit. Planning docs and code go in separate commits.
- **Module documentation.** Any change to a `modules/` package's exported API, config keys, CLI or dependencies updates that module's `README.md` in the same change. `pact` and `cabana` both change here. The affected `docs/` pages change with them, and `go test ./cmd/summer -run TestDocsTree` must pass. Every identifier named in a README or doc must exist.
- **Framework READMEs never name a consuming application.** Use `blog`/`acme` examples, not Journal or grzybyfunkcjonalne.
- **Core plugin contracts.** Do not break the shared PHP contracts. The PHP originals are not changed.
- **Concurrent session.** Another session may be editing `modules/` (16.1 D-02). Stage files explicitly by path and check for in-flight edits to shared cabana files before committing.
## Summary
The framework half is a new, value-bearing extension point that sits next to the valueless Phase 10.1 `type: widget`. Today every layer hard-codes its type list:
- `formFieldTypes` in `form_schema.go`
- `scalarFormField` in `crud.go`
- `mlFieldType` in `field_ml.go`
- `listColumnTypes` in `list_schema.go`
- the SPA `renderers` map and `ML_FIELD_TYPES`
Each list must consult a per-boot plugin type registry, built in `cabana.Activate` from plugins that implement a new `pact.HasFormFieldTypes`. Three constraints shape the design:
- **Thread the registry through.** `compileFieldNode` is a pure function with no registry access, and `decodeFields` is shared by controller forms, settings forms and relation child forms. The registry has to be passed down, not read from a package global.
- **Keep the YAML pre-check working.** The YAML key pre-check (`formFieldKeys`) runs before the type is known, so plugin-specific keys must be validated after the type is resolved.
- **Run Decode and Load inside the transaction.** `liftMLValues` runs before the transaction and before the record is loaded. Plugin Decode and Load need the loaded record (D-07 serves `content_html`; D-03 compares with the served value), so they run inside the transaction after `loadRecord`.
Two findings change the plan:
- **The Journal media upload route is unreachable from the admin SPA (D-18).**
- The SPA authenticates with the HttpOnly `summer_admin` cookie, whose `Path` is the admin prefix.
- The route lives at `/_journal/api/v1/media/upload`, outside that path, and its guard reads only a Bearer header.
- The element is never given a token (Phase 10.1 rule).
- The recommended fix: a cabana-owned upload route for plugin field types. It sits under the admin API, so it gets the cookie, the CSRF header, permissions and the record scope. Journal implements it by calling the same storage code as the existing route.
- **TipTap's default schema is very lossy on this site's real markup.** Probed against 3.31.4 with the author-footer HTML from the grzybyfunkcjonalne export, the default schema:
- drops `<footer>`, `<div>`, `<span style>`, `<style>`, `<figure>`/`<figcaption>` and microdata attributes;
- normalizes entities;
- rewrites tables (adds `colgroup` and `min-width` styles, drops `thead`, wraps cell text in `<p>`);
- adds `target="_blank" rel="noopener noreferrer nofollow"` to every link;
- appends an empty `<p>`.
Byte preservation therefore has two layers:
- The element never re-serializes an untouched document, and the server skips a submitted value that equals what it served.
- For edited documents, the custom schema is configured to keep as much markup as possible: an atomic raw-HTML block, a global attribute map, figure, embed and span nodes, and links and tables without TipTap's default attributes.
There is also a latent gap. The admin path never writes the non-default locale `content_html` translation; only the host column is set by `FormBeforeUpdate`. PHP's `MLJournalMarkdown::getSaveValue` wrote both per locale. The plugin ML type must close this gap, so the framework has to tell the Journal hooks which field and locale values a save actually wrote.
**Primary recommendation:** Add `pact.HasFormFieldTypes` returning `[]pact.FormFieldType` (a struct of funcs, the same style as `pact.AdminAction`) with `Name`, `Element`, `Translatable`, `Scripts`/`Styles`, option keys plus `CheckOptions`, `Decode`, `Load`, `ListValue` and an optional `Upload`. Cabana runs Decode and Load per locale inside the save transaction, skips values equal to what it served, and exposes the writes to Form hooks through a context accessor. Build the Blog Editor as a light-DOM custom element `golem15-journal-blogeditor` on TipTap 3.31.4 with a preservation-first schema, bundled with the admin SPA's pinned Vite 7.3.5.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Field type registration, alias collision, option validation | API / Backend (cabana boot) | — | Fail-loud boot rule (ADMIN-07 precedent); no request input involved |
| Decode/validate (CheckHTML) per locale, untouched skip | API / Backend (cabana save tx + plugin Decode) | — | XSS gate must be server-side on every write path (D-10); hooks only see the host model |
| Load (markdown post → `content_html`) | API / Backend (plugin Load in Show/save response) | — | Needs the record and translations; the element must receive the served string |
| Locale storage (ML variant) | API / Backend (cabana lift/hydrate + TranslationWriter) | Database (translate overlay) | D-14: not reimplemented per plugin |
| `content_html` companion write (host + non-default) | API / Backend (Journal Form hooks) | — | Journal-specific derived column; needs to know which values were written |
| Global field-type asset delivery | API / Backend (cabana asset route + boot endpoint) | Browser (SPA loader) | Assets are embedded and hashed at boot (D-13, 10.1 D-16) |
| WYSIWYG editing, source toggle, dirty tracking | Browser (custom element) | — | Pure client concern; SPA build gains no editor dependency (D-16) |
| Value binding element ↔ form state | Browser (SPA generic PluginField) | — | Replaces UnsupportedField fallback for plugin types |
| Image upload transport | Browser (SPA posts with admin session) | API / Backend (cabana upload route → plugin Upload) | Element never holds credentials (10.1 rule) |
| Image storage | API / Backend (Journal media store) | CDN / Static (nginx `/storage/app/uploads/public`) | Same key layout and public URL as the existing route |
| Embed placeholder → iframe | Frontend Server (SSR, vue-journal-layer) | Browser (hydration) | D-05/D-17: iframes exist only after the frontend gate |
## Standard Stack
### Core (JS, sm-journal-plugin only; the admin SPA gains no editor dependency per D-16)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@tiptap/core` | 3.31.4 (published 2026-09-30, MIT) | Editor API, `Node`/`Extension`/`Mark` factories | Locked by D-16 [VERIFIED: npm registry: `npm view @tiptap/core version license`] |
| `@tiptap/pm` | 3.31.4 (MIT) | ProseMirror re-export (model/view/state/tables) | Peer dependency of core; pins prosemirror-model ^1.25.11, prosemirror-view ^1.42.3 [VERIFIED: npm registry] |
| `@tiptap/starter-kit` | 3.31.4 (MIT) | Paragraph, headings, bold/italic, lists, blockquote, link, history, trailing node | Configure with `trailingNode: false` and a reconfigured `link` (see Pitfalls) [VERIFIED: node_modules/@tiptap/starter-kit/src/starter-kit.ts:166-169] |
| `@tiptap/extension-table` | 3.31.4 (MIT) | `TableKit` (table, row, header, cell) | Official table support; override `renderHTML` to stop the colgroup/min-width output [CITED: tiptap.dev/docs/editor/extensions/nodes/table] |
| `@tiptap/extension-image` | 3.31.4 (MIT) | `img` node (block by default, `allowBase64: false`) | Base for the figure/caption node; base64 refused by default [VERIFIED: node_modules/@tiptap/extension-image/src/image.ts:87-90,127] |
### Supporting (build/test, devDependencies of the plugin JS build, versions pinned to the admin SPA's lockfile)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `vite` | 7.3.5 | Library-mode build of `assets/src` to one ES module `assets/dist/blogeditor.js` (+ `.css`) | Same pin as `admin/package.json`; avoids a second bundler in the repo set [VERIFIED: summercms.go/admin/package.json] |
| `vitest` | 3.2.7 | Unit tests of the serializer, dirty tracking, embed node | Same pin as the admin SPA [VERIFIED: admin/package.json] |
| `happy-dom` | 20.11.6 | DOM for vitest; also used in this session's probes (TipTap runs on it) | Same pin as the admin SPA [VERIFIED: admin/package.json; local probe ran Editor on happy-dom] |
| `typescript` | 5.9.3 | Type-check the element source | Same pin as admin SPA and vue-journal-layer |
Go side: **no new dependency.** It uses `encoding/json` and `strings` in the Journal Decode/Load, and the existing `gocloud.dev/blob` in the media store.
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Vite library mode | esbuild 0.28.2 directly | esbuild is smaller and faster, but it adds a second toolchain next to the admin's pinned Vite. It also has a `postinstall` (`node install.js`) binary fetch. Vite 7.3.5 already uses esbuild internally. |
| Light-DOM element + tag-scoped CSS | Shadow DOM | Shadow DOM isolates styles, but ProseMirror selection inside shadow roots has historically had browser quirks. The 10.1 rule already restricts plugin CSS to `--c-*` variables, and those variables inherit either way. [ASSUMED] |
| One bundle (~430 KiB min / ~135 KiB gzip) | Lazy chunk loaded on first element connect | Lazy loading needs a declared-but-not-auto-loaded asset, which the 10.1 asset mechanism does not model. One bundle is revalidated with `ETag`/304 per page. |
**Installation (in `../sm-journal-plugin`):**
```bash
npm init -y # or a hand-written package.json with "private": true, "type": "module"
npm install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4 @tiptap/extension-table@3.31.4 @tiptap/extension-image@3.31.4
npm install --save-exact -D vite@7.3.5 vitest@3.2.7 happy-dom@20.11.6 typescript@5.9.3
```
**Bundle size (measured):** StarterKit + TableKit + Image + core bundled with esbuild 0.28.2 `--minify --format=esm`: 440,767 bytes minified, 138,583 bytes gzip -9. [VERIFIED: local build in session scratchpad]
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| @tiptap/core | npm | latest 3.31.4 published 2026-09-30 (9 days) | 21.5M/wk | github.com/ueberdosis/tiptap | [SUS] reason `too-new` (the release, not the package) | Flagged: planner adds checkpoint:human-verify before install |
| @tiptap/pm | npm | 3.31.4, 9 days | 21.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
| @tiptap/starter-kit | npm | 3.31.4, 9 days | 18.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
| @tiptap/extension-table | npm | 3.31.4, 9 days | 6.0M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
| @tiptap/extension-image | npm | 3.31.4, 9 days | 9.3M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
| @tiptap/extension-link | npm | 3.31.4 (via starter-kit) | 19.5M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged (transitive) |
| esbuild | npm | 0.28.2 | 299M/wk | github.com/evanw/esbuild | [OK]; `postinstall: node install.js` (platform binary fetch, known) | Not recommended directly; arrives via Vite |
| vite, vitest, happy-dom, typescript | npm | pinned versions already in `summercms.go/admin/package-lock.json` | — | — | not re-run (already vetted in Phase 10) | Approved (same pins) |
**Packages removed due to [SLOP] verdict:** none.
**Packages flagged as suspicious [SUS]:** every `@tiptap/*` package, for the single reason `too-new`. The 3.31.4 release is 9 days old; the packages themselves have tens of millions of weekly downloads and none has a `postinstall` script.
- The planner adds a `checkpoint:human-verify` before the first `npm install`.
- Alternatively, pin an earlier 3.x release that is more than 14 days old. The package names come from TipTap's official docs and npm (`[VERIFIED: npm registry]`) and the seam was run (`msd-tools query package-legitimacy check`).
## Architecture Patterns
### System Architecture Diagram
```
BOOT (cabana.Activate)
plugins ──HasFormFieldTypes──> fieldTypeRegistry ──┬─> alias collision check vs core types + other plugins (fail boot)
│ ├─> read+hash Scripts/Styles from AdminFS (reuse compileClientAssets reader)
│ └─> passed into decodeFields(...) for controller / relation-child forms
│ (settings forms: refuse plugin types at boot in v1)
▼
fields.yaml `type: mlblogeditor` ──> compileFieldNode: core key check, then plugin keys ──> CheckOptions ──> FormField{type, element, translatable, typeOptions}
ADMIN SPA
login ─> app shell ─> GET /field-types (or navigation meta) ─> loadScript(each) once, global CSS always enabled
FormView ─> rendererFor(type) ─ unknown/plugin ─> PluginField ─(translatable)─> MLField(inner = PluginField)
PluginField ── el.value = served string ──> <golem15-journal-blogeditor>
<── 'summer-change' {value} ── (element keeps original string until a user edit)
<── 'summer-upload' {id,file} ── ─> POST {api}/{v}/{p}/{c}/{id}/fields/{field}/upload (cookie + X-Requested-With)
── 'summer-upload-result' {id,url|error} ──>
SAVE (PUT {api}/{v}/{p}/{c}/{id})
body ─> lift plugin values (shape: plain any JSON | ML map of enabled locale -> string)
─> tx: loadRecord(FormExtendQuery scope)
─> per (field, locale): served := Load(record, locale); if update && submitted == served -> SKIP
else Decode(...) -> CheckHTML -> 422 FieldError | stored value
─> default-locale values onto body -> Fill ; non-default -> translations list
─> ctx += written {field: {locale: value}} (accessor for hooks)
─> FormBeforeUpdate (Journal: content_html := content only if content written)
─> row Save ─> applyMLTranslations ─> FormAfterUpdate (Journal: WriteTranslated content_html per written non-default locale)
─> projectFullRecord ─> hydrate: Load per locale ─> response
PUBLIC SITE (vue-journal-layer)
API content_html ─> gateJournalHtml (3 regexes, refuses raw iframe) ─> expandEmbeds (allowlist + id regex) ─> v-html
```
### Recommended Project Structure
```
summercms.go/
├── modules/pact/capabilities.go # + FormFieldType, FormFieldInput, FieldError, HasFormFieldTypes, FormFieldUpload*
├── modules/cabana/field_type.go # NEW: registry build, collision/alias rules, option check, Decode/Load runner, list formatter
├── modules/cabana/field_type_upload.go # NEW: POST .../{id}/fields/{field}/upload handler
├── modules/cabana/form_schema.go # decodeFields/compileFieldNode take the registry; plugin keys after type resolution
├── modules/cabana/crud.go # save(): plugin lift/decode/skip in tx; written-values context; scalarFormField registry-aware
├── modules/cabana/field_ml.go # mlFieldType registry-aware; hydrate calls Load
├── modules/cabana/admin_openapi.go # new route(s) + FormField generic fields
├── admin/src/components/form/fields/PluginField.vue # NEW generic element host
├── admin/src/app/fieldTypes.ts # NEW global loader (scripts once, CSS never disabled)
└── docs/backend/field-types.md (or a section in partials-and-widgets.md) + site.yaml nav
sm-journal-plugin/
├── assets/src/blogeditor/ # element.ts, schema/*.ts (rawHtml, figure, embed, span, globalAttrs, table), serializer.ts
├── assets/dist/blogeditor.js|.css # committed, embedded via //go:embed in admin.go
├── classes/blog_editor.go # NEW: FormFieldType values (blogeditor, mlblogeditor): Decode/Load/ListValue/Upload
├── classes/media_store.go # NEW: storage function extracted from controllers/api/media.go (shared)
├── controllers/posts.go, categories.go # hooks: no FormatHTML on admin path; content_html companion writes
├── package.json, package-lock.json, vite.config.ts, scripts/check-dist.sh
└── models/post/fields.yaml (mlblogeditor), models/category/fields.yaml (blogeditor)
vue-journal-layer/app/utils/embeds.ts # NEW: expandEmbeds(html) after gateJournalHtml; PostBody + CategoryHeader
```
### Pattern 1: Per-boot field type registry threaded through compile
**What:** Collect `pact.HasFormFieldTypes` from every activated plugin in `Activate` and pass the registry into `compileRegistry` and from there into `decodeFields` and `compileFieldNode`. Validate aliases with an identifier rule, for example `^[a-z][a-z0-9]*$`, and reject:
- duplicates;
- any name in the core `formFieldTypes` set;
- the reserved names `widget` and `partial`.
**Verified integration points (quoted from source):**
- The closed type set [VERIFIED: modules/cabana/form_schema.go:24-30]:
`"text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "password": {}, "permissioneditor": {}, "markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},`
- The unknown-key check runs before the type is known [VERIFIED: modules/cabana/form_schema.go:466-468]: `if _, known := formFieldKeys[key]; !known { return FormField{}, fmt.Errorf("unknown field %s", key) }`. The type is resolved afterwards [form_schema.go:474-480]. Plugin keys therefore need either a two-pass key check or a resolve-type-first reorder.
- `decodeFields(raw []byte)` has three callers: form_schema.go:194 (controller form), settings.go:74 (settings), relation_form.go:145 and :246 (relation child and pivot forms). [VERIFIED: grep + Read of form_schema.go]
- Registry construction lives in `Activate`, http.go:74-121: `collectControllers` → `compileRegistry(items)` → `compileContributions(reg, plugins)`. `compileRegistry` receives only `items []controllerRef`, so it needs a new parameter. [VERIFIED: modules/cabana/http.go:75-119; registry.go:57]
- The save classifier [VERIFIED: modules/cabana/crud.go:1224-1232]: `case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker", "markdown", "mltext", "mltextarea", "mlmarkdown": return true`. `BindWritableFields` (crud.go:204) and `mergedRules` (crud.go:1073) use it, so a plugin type bound to a column must count as writable.
- ML classifier [VERIFIED: modules/cabana/field_ml.go:11-15, 47-53]: `mlTextFieldType = "mltext"`, `mlTextareaFieldType = "mltextarea"`, `mlMarkdownFieldType = "mlmarkdown"`.
- List column types [VERIFIED: modules/cabana/list_schema.go:22-24]: `"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},`. List rows are projected by `projectRow` (http.go:1067), which copies raw field values. Columns typed with a plugin alias call `ListValue`.
- The widget tag rule to reuse for `Element` [VERIFIED: modules/cabana/extension.go:18, 48-50]: `^[a-z][a-z0-9]*(-[a-z0-9]+)+$`, and the prefix is the plugin ID with `.`/`_` → `-` plus `-` (`golem15.journal` → `golem15-journal-`).
**Recommendation:** Do not use a package-level mutable registry. Tests construct several services in one process; the existing code builds everything per `Activate`. Use a small `fieldTypes` value passed as a parameter, or attached to a compile-context struct.
### Pattern 2: Save lifecycle for plugin types (D-12 items 2-3, D-14)
**What:** Plugin values are handled in two places:
1. **Before the transaction**, a shape check. It reuses the `liftMLValues` rules for a translatable type: an object of enabled locale → string with the default locale required. A plain type accepts any JSON value.
2. **Inside the transaction after `loadRecord`**, a per-locale Load → compare → Decode step.
Decoded default-locale values go onto the body, so `lagoon.Fill` applies them, `Fillable` and `context:` are still enforced, and `FormBeforeUpdate` sees them on the model. Non-default values join the existing `translations` list for `applyMLTranslations`.
**Why inside the transaction:** the current order in `save()` is: ML lift at crud.go:600 (before the transaction), then `loadRecord` (crud.go:616), Fill, BeforeValidate, Validate, `formBeforeUpdate`, row Save, `applyMLTranslations` (crud.go:688), `formAfterUpdate`, `projectFullRecord` and `hydrateMLRecord` (crud.go:709). [VERIFIED: modules/cabana/crud.go:567-711] Load needs the record, for example to serve `content_html` for a markdown post (D-07), so the untouched comparison can only run after the load.
**Error mapping:** pact cannot import cabana, so it cannot return `*cabana.ValidationError`. Define `pact.FieldError{Messages []string}`. Cabana maps it to a 422 on that field. Any other error becomes the opaque 500 or lifecycle error, so internal error text never leaks. This matches the existing `ValidationError`/`ForbiddenError` handling in `lifecycleFailure` (crud.go:815-850).
**Written-values accessor (closes the companion-column gap):** after the decode step, put the written values on the context, the same pattern as `withVirtualFields`/`VirtualFieldsFromContext` (tx_context.go:38-54), for example `cabana.FieldWritesFromContext(ctx) map[string]map[string]string`, which maps field → locale → value for the values actually written. Form hooks read it:
- **Journal `FormBeforeCreate`/`FormBeforeUpdate`:** if `content` was written in the default locale, set `ContentHTML = Content`; otherwise leave `ContentHTML` alone, which keeps it byte-identical.
- **Journal `FormAfterCreate`/`FormAfterUpdate`:** for each written non-default locale of `content`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)`. The hook runs after `applyMLTranslations` (crud.go:688 runs before `formAfterUpdate` at :698), so the host row has its id.
### Pattern 3: Two-layer untouched detection (D-03)
The **client** never re-serializes an untouched document:
- the element stores the exact string it was given;
- its `value` getter returns that string until a user-originated `docChanged` transaction or a source-textarea `input`;
- the initial content goes in with `emitUpdate: false`.
The **server** skips any per-locale value equal to the value it served (`Load` output) on update, so neither column, nor the translation, is written. Both layers are needed:
- The client layer alone fails for a markdown post. The element is served `content_html`, so an untouched save would send `content_html` and overwrite the markdown `content`. The server comparison stops that.
- It also fails for imported posts whose `content_html` differs from `content`. In the local grzybyfunkcjonalne export, 17 of 100 pl translations differ, for example `&quot;` versus `"`. [VERIFIED: analysis of /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/*.json in this session; note this export is from March 2026 and older than UAT data]
- The server layer alone fails because TipTap normalizes on load, as Pitfall 1 shows, so an editor that re-serialized on load would never match.
**Generic semantics:** "a value equal to what the form was served is not written" holds for any plugin type, so cabana can apply it without the plugin opting in.
### Pattern 4: Preservation-first editor schema (D-03 "as far as possible", D-04)
Build these on top of `StarterKit.configure({ trailingNode: false, link: { HTMLAttributes: { target: null, rel: null, class: null }, openOnClick: false } })`:
1. **`rawHtml` atomic block node:**
- `atom: true`, `group: 'block'`, low priority.
- `parseHTML` rules for block tags the schema does not model: `footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, and `div` (or only a `div` with attributes).
- It stores the outer HTML.
- The editor shows it as a non-editable card that opens in the source tab. A probe showed it keeps the footer and `<style>` (with `itemscope` → `itemscope=""` normalization). ProseMirror's parser ignores `style` by default, but a matching rule overrides that. [VERIFIED: local probe; prosemirror-model dist ignoreTags = `head, noscript, object, script, style, title`]
2. **Global attribute map:** an `Extension` with `addGlobalAttributes` over the modelled node types. One `htmlAttrs` attribute captures every attribute the node does not model and renders it back. In the probe it kept `class`/`data-x`/`itemprop`; attribute order can change. [VERIFIED: local probe; CITED: tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing (`types: '*' | 'nodes' | 'marks'`)]
3. **`span` mark** with the same attribute map, for inline `style`/`class` spans such as the footer's `<span style="color: #138c74;">`.
4. **`figure` node** containing an image plus an optional `figcaption` (D-18 caption). Without it, the probe showed `<figure>` unwrapped and `figcaption` becoming a `<p>`.
5. **`embed` atomic node**, rule `figure[data-embed]` with higher priority than `figure`. It renders exactly `<figure data-embed="youtube" data-id="ID"></figure>`; the probe round-tripped this exactly.
6. **Table:** `TableKit` with a `renderHTML` override that emits no `colgroup` or `min-width` style.
7. **Custom serializer for `getValue()`:** do not use `editor.getHTML()` for the stored value. Walk the top-level nodes:
- emit `rawHtml` nodes verbatim;
- for other nodes, use `DOMSerializer.fromSchema(schema).serializeNode(node)`.
An optional refinement: at load, parse each top-level DOM child on its own and remember (nodes, outerHTML). On serialize, emit the remembered outerHTML for any run of top-level nodes that are still `Node.eq` to the originals. Unedited paragraphs then stay as the browser serialized them, which reduces diff churn on long imported posts. This is a design proposal [ASSUMED]; `Node.eq` exists in prosemirror-model [VERIFIED: node_modules/prosemirror-model/dist/index.js:217].
**The source tab** is a `<textarea>` bound to the exact string. Switching source → WYSIWYG re-parses; that counts as an edit only if the text changed.
### Pattern 5: Element contract (D-13), extending the 10.1 widget contract
- **Tag:** the plugin prefix rule (`golem15-journal-blogeditor`). It is defined by the plugin script, loaded globally after login.
- **Inputs:**
- The `value` property (string for the Blog Editor; any JSON for other types).
- Attributes `field-name`, `locale`, `record-id` (empty on create) and `invalid`, `disabled`/`readonly`.
- Attribute `options` with the JSON of the type's YAML options.
- Setting `value` to the string the element last emitted must be a no-op, so there is no cursor reset on echo.
- **Outputs (all bubbling `CustomEvent`s):**
- `summer-change` with `detail.value`, emitted only on user edits.
- `summer-upload` with `detail { id, file }`. The SPA posts the file to the field upload route with the admin session and answers on the element with `summer-upload-result` `{ id, url } | { id, error }`.
- **Never handed to the element:** a token, a cookie, a function or a Vue instance (WidgetField.vue header rule). [VERIFIED: admin/src/components/form/fields/WidgetField.vue header comment]
- **Event-name precedent:** `WIDGET_EVENT = 'summer-action'` and `WIDGET_RESULT_EVENT = 'summer-result'`. [VERIFIED: admin/src/components/form/formContext.ts:86,93]
- **SPA changes:**
- `rendererFor` falls back to `PluginField` for types carrying `element`.
- `isRegistered` returns true for plugin types.
- `valueless` stays `[RELATION_MANAGER, 'widget', 'partial', 'fileupload']` [VERIFIED: registry.ts:87].
- `isMLFieldType(type)` (`ML_FIELD_TYPES = new Set(['mltext', 'mltextarea', 'mlmarkdown'])` [VERIFIED: mlLocale.ts:2]) must also accept plugin fields flagged `translatable`. Its callers in `formState.ts` (`editablePayload`, `initialValues`, `adopt`, `recordDisplayName`) pass a type string, so either switch them to a field-based check or register translatable plugin types at schema load.
- **CSS:** `loadStyles`/`activateStyles` disable stylesheets owned by other controllers [VERIFIED: admin/src/app/pluginAssets.ts]. Field-type CSS needs an owner that is never disabled. Scope its selectors under the element tag and use only `--c-*` variables (cabana README:134).
### Pattern 6: Journal integration
- **Register both types on the Plugin**, not on a controller, because field types are global (D-13). Embed `assets/dist/blogeditor.js` and `.css` through the existing `//go:embed` list in `admin.go`. The assets are served at `<prefix>/assets/golem15/journal/dist/blogeditor.js?v=<hash>` (key = `vendor/plugin/<path after assets/>`, extension.go:281).
- **Decode (both types):** check that the value is a string, run `classes.CheckHTML(value)` and return the string unchanged. CheckHTML errors become `pact.FieldError`. The gate patterns [VERIFIED: sm-journal-plugin/classes/format_html.go:25-27]:
- ``(?i)<(?:script|iframe|object|embed)\b``
- ``(?i)\son[a-z]+\s*=``
- ``(?i)(?:javascript|vbscript|data):``
- **Load (post `content`):** if the stored `content` looks like HTML (first non-space byte is `<`), serve `content`. Otherwise it is a markdown post, so serve `content_html` for that locale. For a non-default locale, read it through the app's `cabana.TranslationWriter` (`TranslatedExact(..., "content_html", locale)`). This "looks like HTML" rule is a heuristic [ASSUMED]; see the Assumptions Log.
- **Hooks:** `preparePostWrite` (posts.go:213-219: `html, err := classes.FormatHTML(post.Content)` … `post.ContentHTML = html`) stops calling `FormatHTML` on the admin path and uses the written-values accessor instead (Pattern 2). The API path (`controllers/api/posts_helpers.go`) keeps `FormatHTML` (D-09). [VERIFIED: sm-journal-plugin/controllers/posts.go:213,219]
- **Fields:**
- `models/post/fields.yaml:19` changes `type: mlmarkdown` → `type: mlblogeditor`.
- `models/category/fields.yaml:28` changes `type: textarea` → `type: blogeditor`.
[VERIFIED: grep -n of both files]
- **Category content is not translatable:**
- Go model: `Content *string` … `// HTML body (D-21): stored and served as is, not translatable` [VERIFIED: models/category.go:12].
- `func (Category) Translatable() []string { return []string{"name", "description", "slug"} }` [VERIFIED: models/category.go:26].
- The frozen PHP `Category::$translatable` is `'name', 'description', ['slug', 'index' => true]`, and the PHP category form has no content field at all [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php:36-40, models/category/fields.yaml].
- So D-19 resolves to **`blogeditor`**.
- `Content` is `*string`: Decode/Load must handle a nil pointer, and `prepareCategoryWrite` still maps blank to NULL.
- **Post translatable attributes:** `return []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}` [VERIFIED: models/post.go:43-45]. `content_html` is translatable, so the non-default companion write is required.
### Pattern 7: Embed expansion in vue-journal-layer (D-17)
Order matters: `gateJournalHtml(html)` runs first and refuses any raw iframe in stored HTML. Then `expandEmbeds(gated.html)` replaces only exact placeholders, `<figure data-embed="youtube" data-id="([A-Za-z0-9_-]{11})"></figure>` (optionally with a `figcaption`), with:
```html
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/ID" ...></iframe></figure>
```
- The transform is string-based and SSR-safe; there is no DOM on the server.
- It applies in both v-html sinks: `PostBody.vue:11` and `CategoryHeader.vue:17` [VERIFIED: grep of vue-journal-layer/app].
- Unknown providers or malformed ids stay inert.
- The site's nginx config sets no CSP, so the iframe needs no header change [VERIFIED: grep of sm-grzybyfunkcjonalne-app/deploy/nginx shows only HSTS and nosniff `add_header`].
- Privacy-enhanced mode is the `youtube-nocookie.com` embed domain [CITED: support.google.com/youtube/answer/171780].
### Pattern 8: Plugin JS build (D-16)
Mirror `scripts/check-admin-dist.sh`, which rebuilds into a temporary directory and fails on `diff -r` against the committed `dist`. The plugin gets:
- `package.json` with exact pins and a `package-lock.json`;
- `vite.config.ts` in library mode (`build.lib` with `formats: ['es']`, `fileName: 'blogeditor'`, a stable output name with no content hash, because cabana adds `?v=` itself);
- `scripts/check-dist.sh`.
The Go build never runs npm, so a host app needs no Node (D-16). Do not let the bundle depend on `eval`/`new Function`: the admin CSP is `"frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"` [VERIFIED: modules/boardwalk/boardwalk.go:32]. There is no `style-src`, so TipTap's injected style tag (`injectCSS`, default true) is allowed. It is still cleaner to set `injectCSS: false` and ship the CSS in `blogeditor.css`.
### Pattern 9: Field upload route (resolves D-18 reachability; see Open Question 1)
The route is `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`, multipart with one `file` part, where `{id}` 0 means the create form, the same convention as the fileupload routes. Cabana owns:
- the backend guard (cookie or Bearer);
- `requireAjax`;
- the controller's `RequiredPermissions`;
- the record scope through `FormExtendQuery` when `id != 0`;
- the body cap `http.body_limits.upload_bytes` (`s.uploadBytes`);
- "the field exists in the form, its type is a plugin type with an `Upload` func, and its context allows the operation".
It then calls `Upload(ctx, pact.FormFieldUpload{Record, Field, Filename, Body})`. That returns `{URL string}` or a `FieldError`, and cabana answers 201 `{data:{url}}`.
**Pattern collision check:** the shape `/{v}/{p}/{c}/{id}/fields/{field}/upload` is distinct from the existing 7-segment POST patterns:
- `.../{id}/files/{field}/reorder`
- `.../{id}/relations/{name}/link`
They differ by the literal segment, so `ServeMux` accepts them all. The 6-segment shape `/{v}/{p}/{c}/fields/{field}/upload` would collide with `POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}`: neither is more specific, the same problem `nestedGet` documents. [VERIFIED: route table read in modules/cabana/http.go:241-345]
A new route must be:
- added to `admin_openapi.go`;
- added to `phase09Routes`, because `TestPhase09PermissionMatrix` and `TestPhase09ContractInventory` fail on divergence;
- regenerated through `scripts/check-admin-openapi.sh`.
### Anti-Patterns to Avoid
- **Running Decode/CheckHTML in Form hooks only.** Hooks see the host model, never the non-default locale values (`translations` stay inside cabana), so D-10 "every locale" cannot be met there.
- **Using `editor.getHTML()` as the stored value.** It normalizes entities, attributes, tables and links even for unchanged content (Pitfall 1).
- **Calling `editor.commands.setContent(html)` with the defaults when syncing an external value.** `emitUpdate` defaults to `true` in v3 (it was `false` in v2), so the echo fires `update` and marks the editor dirty. [CITED: tiptap.dev/docs/editor/api/commands/content/set-content] [VERIFIED: local probe: one update per default `setContent`, none with `{ emitUpdate: false }`]
- **A package-level plugin type registry** in cabana (tests build several services per process).
- **Handing the element a token or calling `/_journal/api/v1/media/upload` from it** (unreachable, and it breaks the 10.1 credential rule).
- **Relaxing `CheckHTML` to allow iframes** (rejected; D-05 and Deferred).
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Rich-text editing, selection, undo, paste handling | contenteditable + execCommand | TipTap 3.31.4 (ProseMirror) | Locked D-16; contenteditable has notoriously inconsistent cross-browser behavior [ASSUMED] |
| Table editing (cell selection, merge, keyboard nav) | Custom table UI | `TableKit` (prosemirror-tables) | Edge cases in cell selection and colspan handling |
| Locale storage for the ML variant | Per-plugin translation writes | cabana `liftMLValues`/`applyMLTranslations`/`hydrateMLRecord` + published `TranslationWriter` | D-14; the existing path already validates locales and the default key |
| Asset hashing/serving/ETag | A second asset pipeline | `compileClientAssets` reader + `pluginAsset` route | 10.1 machinery; refactor the reader for plugin-level assets |
| Upload auth/CSRF/permissions/scoping | A plugin route that reads the cookie | cabana-owned field upload route (Pattern 9) | The framework owns these checks for actions and file routes already |
| HTML sanitization | A sanitizer | `classes.CheckHTML` refuse-only gate | Locked D-10 (no sanitizer dependency) |
| The dist drift gate | Ad-hoc checks | Copy of `scripts/check-admin-dist.sh` | Proven pattern (temp build + `diff -r`) |
**Key insight:** losslessness here does not come from a perfect schema, because no ProseMirror schema round-trips arbitrary HTML. It comes from not serializing unless the user edited, plus a server that refuses to write what it served.
## Common Pitfalls
### Pitfall 1: TipTap's default schema silently rewrites imported HTML
**What goes wrong:** Opening and re-serializing loses footers, styles, spans, microdata, figures and table structure.
**Evidence (local probe, @tiptap/* 3.31.4 on happy-dom 20.11.6):**
- The input `<p>Tekst.</p>\n\n<footer class="author-footer" itemscope itemtype="https://schema.org/Person">…<style>…</style>` came out as `<p>Tekst.</p><img src="/a.png" alt="A"><p>Marcin</p>`.
- `&quot;` became `"`.
- `<table><thead>…` came out as `<table style="min-width: 50px;"><colgroup>…</colgroup><tbody><tr><th><p>A</p></th>…</tbody></table><p></p>`.
- `<figure><img><figcaption>Cap</figcaption></figure>` came out as `<img …><p>Cap</p>`.
[VERIFIED: probe output this session]
**How to avoid:** Use Patterns 3 and 4.
**Warning signs:** A vitest that sets `value` and reads `value` without any edit returns a different string.
### Pitfall 2: Link extension adds attributes to every link
**What goes wrong:** Every re-serialized `<a>` gains `target="_blank" rel="noopener noreferrer nofollow"`.
**Why:** The defaults are `HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow', class: null }` [VERIFIED: node_modules/@tiptap/extension-link/src/link.ts:263-267].
**How to avoid:** Configure `link: { HTMLAttributes: { target: null, rel: null, class: null }, openOnClick: false }` and preserve the original `target`/`rel` through the attribute map. Keep the default `isAllowedUri` (it refuses `javascript:`).
### Pitfall 3: The trailing node appends `<p></p>`
**What goes wrong:** StarterKit v3 includes `TrailingNode`, so a document that ends in a table or another atom gains an empty paragraph.
**How to avoid:** `StarterKit.configure({ trailingNode: false })` [VERIFIED: local probe, trailing `<p></p>` gone with the option].
### Pitfall 4: `setContent` emits `update` by default in v3
**How to avoid:** Pass the initial value through the `content` option or `setContent(v, { emitUpdate: false })`. Track dirty from user transactions only. [VERIFIED: probe; CITED: tiptap set-content docs]
### Pitfall 5: The 16.1 stopgap is not byte-preserving
**What goes wrong:** Since 16.1, every admin post save runs `FormatHTML(post.Content)` (goldmark with `html.WithUnsafe()`, format_html.go:22) and stores the result in `content_html`.
- goldmark trims the input and adds a trailing `\n`.
- CommonMark HTML-block rules can turn 4-space-indented HTML into code blocks, and can process markdown between blank-line-separated HTML blocks. [ASSUMED from CommonMark spec behavior]
- The 16.1 test compares `strings.TrimSpace(stored.ContentHTML)` [VERIFIED: posts_admin_smoke_test.go:146], so it cannot detect byte drift.
**How to avoid:** Replace the stopgap on the admin path (Pattern 6). Tighten the test to exact bytes and add the untouched case: a PUT with the served value must leave `content`, `content_html` and `updated_at`-independent columns unchanged.
### Pitfall 6: Saving an untouched markdown post converts it
**What goes wrong:** Without the server-side skip, an untouched markdown post sends `content_html` back as `content`, a silent format migration with no edit. That contradicts D-03, and also D-07's "on first edit".
**How to avoid:** Use the Pattern 3 server skip.
### Pitfall 7: Non-default `content_html` is never written by the admin today
**What goes wrong:** `FormBeforeUpdate` sets only the host `ContentHTML`. `applyMLTranslations` writes only the field itself (`content`). So an `en` edit leaves the `en` `content_html` stale. PHP wrote both per locale: `setAttributeTranslated('content', …)` and `setAttributeTranslated('content_html', Post::formatHtml($value), …)` [VERIFIED: fonoteka/plugins/golem15/journal/formwidgets/MLJournalMarkdown.php getSaveValue].
**How to avoid:** Use the Pattern 2 written-values accessor and the companion write in `FormAfter*`.
### Pitfall 8: Field-type CSS gets disabled on navigation
**What goes wrong:** `activateStyles(controllerId)` disables every link whose owner is not the active controller [VERIFIED: admin/src/app/pluginAssets.ts].
**How to avoid:** Add a global owner that `activateStyles` never touches.
### Pitfall 9: Route pattern collision panics at boot
**What goes wrong:** See Pattern 9. Use the `/{id}/fields/{field}/upload` shape and keep `TestPhase09PermissionMatrix` green.
### Pitfall 10: The `data:` substring in prose trips `CheckHTML`
**What goes wrong:** ``(?i)(?:javascript|vbscript|data):`` matches anywhere, including Polish text "Data: 12.03" ("data" is Polish for "date") and "Metadata:". The editor would then answer 422 on harmless text.
**Evidence:** There are 0 occurrences in the local export [VERIFIED: scan this session], so this is a latent false-positive risk, not a current blocker.
**How to handle:** Show the 422 message clearly in the editor. Changing the gate is a security decision and out of scope (D-10).
### Pitfall 11: There is no Docker on this machine
**What goes wrong:** The testcontainers suites cannot run here: the `modules/cabana` Postgres tests and the sm-journal-plugin admin harness, including `TestPostsAdminResaveKeepsHTML` [VERIFIED: `docker info` failed this session; 16.1-01 SUMMARY records the same].
**How to handle:** Plan DryRun/unit coverage that runs with `-short` and a human Docker-host gate before the release (the 16.1-02 "docker-first" pattern).
## Code Examples
### 1. pact surface (proposed; names are Claude's discretion)
```go
// FormFieldType is a plugin-registered, value-bearing admin form field type
// (Winter registerFormWidgets). Name is the global alias used as `type:` in
// fields.yaml; Element is the custom-element tag the plugin's script defines.
type FormFieldType struct {
Name string
Element string
Translatable bool // value is a locale -> string map stored through the translation writer
Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
Styles []string // AdminFS paths under assets/ (.css)
Options []string // the type's own fields.yaml keys
CheckOptions func(options map[string]any) error `json:"-"`
Decode func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
ListValue func(value any) string `json:"-"`
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
}
type FormFieldValue struct {
DB *gorm.DB // the save transaction, or the read handle for Load
Record any // the loaded (or new) model pointer
Field string
Locale string // "" for a non-translatable type
Default bool // Locale is the host-column locale
Value any // submitted (Decode) or stored (Load) value
}
// FieldError is a value refusal shown on the field (422).
type FieldError struct{ Messages []string }
type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType }
```
(`pact` already imports `gorm.io/gorm` and `context` [VERIFIED: modules/pact/capabilities.go:3-13].)
### 2. Journal Decode (Go, no new dependency)
```go
func decodeBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) {
text, ok := in.Value.(string)
if !ok {
return nil, &pact.FieldError{Messages: []string{"golem15.journal::lang.post.content_invalid"}}
}
if err := CheckHTML(text); err != nil { // classes.CheckHTML: refuse, never rewrite (D-10)
return nil, &pact.FieldError{Messages: []string{err.Error()}}
}
return text, nil
}
```
### 3. Element skeleton (TypeScript, light DOM)
```ts
// Source: TipTap Editor/setContent docs + local probes (emitUpdate:false fires no update)
class BlogEditor extends HTMLElement {
#original = '' // exact served string
#dirty = false
#editor?: Editor
get value(): string { return this.#dirty ? serialize(this.#editor!) : this.#original }
set value(v: string) {
if (v === this.value) return // echo of our own summer-change: no reset
this.#original = v; this.#dirty = false
this.#editor?.commands.setContent(v, { emitUpdate: false })
}
connectedCallback() {
this.#editor = new Editor({
element: this.querySelector('[data-pm]')!, injectCSS: false,
extensions: preservationExtensions(), content: this.#original,
onUpdate: ({ transaction }) => {
if (!transaction.docChanged) return
this.#dirty = true
this.dispatchEvent(new CustomEvent('summer-change', { bubbles: true, detail: { value: this.value } }))
},
})
}
disconnectedCallback() { this.#editor?.destroy() }
}
customElements.define('golem15-journal-blogeditor', BlogEditor)
```
### 4. Layer embed expansion (SSR-safe string transform)
```ts
// After gateJournalHtml; the iframe exists only in the rendered page (D-05).
const YOUTUBE = /<figure data-embed="youtube" data-id="([A-Za-z0-9_-]{11})"><\/figure>/g
export function expandEmbeds(html: string): string {
return html.replace(YOUTUBE, (_m, id: string) =>
`<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture" allowfullscreen></iframe></figure>`)
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| TipTap 2 `setContent(content, emitUpdate = false)` | v3 options object, `emitUpdate` default `true` | TipTap v3 | Echo updates mark dirty unless suppressed |
| `History` extension | `UndoRedo` (StarterKit option `undoRedo`) | v3 | Config key name [VERIFIED: starter-kit.ts:101-103] |
| `@tiptap/extension-table-row/-cell/-header` separate packages | `TableKit` from `@tiptap/extension-table` | v3 | One install [CITED: tiptap table docs] |
| StarterKit without link/underline/trailing node | v3 StarterKit includes Link, Underline, TrailingNode | v3 | Must configure or disable [VERIFIED: starter-kit.ts:130-169] |
**Deprecated/outdated:** goldmark rendering of HTML content on the admin path (the 16.1 stopgap) is replaced by D-02/D-10.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | "Looks like HTML" (first non-space byte `<`) identifies an HTML post; anything else is markdown and is served as `content_html` | Pattern 6 | A markdown post starting with an HTML tag would open as raw markdown in WYSIWYG; low frequency (0 of the local export's markdown bodies start with `<`) but needs user OK |
| A2 | Light DOM is preferable to Shadow DOM for ProseMirror | Alternatives | Style leakage if wrong; mitigated by tag-scoped CSS |
| A3 | Top-level `Node.eq` reuse of original block HTML is worth its complexity | Pattern 4 item 7 | Planner may cut it; D-03 only requires "as far as possible" |
| A4 | goldmark can mangle indented HTML (CommonMark HTML block rules) | Pitfall 5 | Only affects the justification for replacing the stopgap, not the decision |
| A5 | Settings forms refuse plugin types in v1 | Pattern 1 | A future settings form wanting a plugin type needs a follow-up |
| A6 | Journal uploads keep the media-bucket layout (`/journal/...`), not `content_images` attachments (PHP parity used attachments) | Pattern 9 | Attachments would need deferred binding with the form session key |
| A7 | contenteditable inconsistency justifies the library (general knowledge) | Don't Hand-Roll | None (D-16 locks TipTap anyway) |
## Open Questions
1. **D-18 upload path reachability (needs user confirmation).**
- What we know:
- The admin SPA never reads the JWT: "the JWT lives in an HttpOnly cookie the SPA never reads" [VERIFIED: admin/src/api/client.ts:2-4].
- The cookie is `Path: s.adminPrefix()`, `HttpOnly: true`, `SameSite: http.SameSiteStrictMode` [VERIFIED: modules/cabana/auth.go:244-252], with `AdminCookieName = "summer_admin"` [VERIFIED: modules/cabana/prefix.go:16].
- The Journal guard is built without cookie names: `return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:80], and the route is `g.Post("/media/upload", ...)` under `/_journal/api/v1` [VERIFIED: routes.go:26].
- So the browser sends no credential to that route from the admin.
- Recommendation:
- Keep the existing route untouched for API clients (D-09 spirit).
- Extract its storage logic into `classes` (or `controllers/api`), keeping these values: `const mediaMaxBytes = 10240 << 10`; extensions `"jpg", "jpeg", "png", "gif", "webp"` [VERIFIED: media.go:17,45]; the `/journal/<folder>/<name>` keys; the unique-suffix logic.
- Call it from the field type's `Upload` through the cabana field upload route (Pattern 9).
- This keeps "the existing Journal media storage" but changes the transport, so confirm the reading of D-18 with the user.
2. **Where the SPA learns the global field-type assets.** Options: a new `GET /field-types` route (adds to the OpenAPI and route matrix), or a `meta.fieldTypes` on `/navigation`, which already loads on every page (`useNavigation.ts:13`). Recommendation: a dedicated route, which keeps the navigation contract unchanged. Planner's choice.
3. **Release scope.** The 16.1 pattern applies: summercms tag v0.1.17 (latest is v0.1.16), a sm-journal-plugin `go.mod` require bump, the host gitlink bump, vue-journal-layer tag v0.2.0 (latest v0.1.0) with the `vue-grzybyfunkcjonalne-app` `package.json` pin `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` [VERIFIED: package.json:20] bumped, the host gitlink for the Vue app, `scripts/build.sh` (builds Go and Nuxt), rsync, restarting both supervisor programs, and the on-server smoke. Recommendation: include it as a plan with human checkpoints (Docker-host gate, UAT browser check of editing an imported post).
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | all Go work | ✓ | go1.27.0 | — |
| Node | plugin JS build, admin SPA, layer | ✓ | v22.23.2 (admin needs >=22.6; layer needs >=22.18) | — |
| npm | plugin build, admin | ✓ | 12.0.2 | — |
| pnpm | `vue-journal-layer` `check-git-install.sh` | not probed | — | Run on the release machine; the script exits early if pnpm is missing |
| Docker | testcontainers suites (cabana Postgres, journal admin harness) | ✗ | — | DryRun/unit tests with `-short`; human Docker-host gate before the tag (16.1 pattern) |
| ssh to UAT (`grzybyfunkcjonalne-uat`) | redeploy | not probed this session (worked in 16.1-02) | — | Human checkpoint |
**Missing dependencies with no fallback:** none blocking the code work.
**Missing dependencies with fallback:** Docker (human-run gate).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go `testing` (+ testify), admin vitest 3.2.7 + happy-dom, plugin vitest 3.2.7 (new), layer `node --test` |
| Config file | `admin/vitest.config.ts`; plugin `vite.config.ts`/`vitest.config.ts` (Wave 0); layer `package.json` `test` script |
| Quick run command | `go test -short ./modules/pact ./modules/cabana -run 'FieldType\|Plugin'` |
| Full suite command | `go vet ./... && go test -short ./...` in summercms.go; `npm --prefix admin test`; in `../sm-journal-plugin`: `go vet ./... && go test -short ./... && npm test`; in `../vue-journal-layer`: `npm test` |
### Phase Requirements → Test Map
| Req | Behavior | Test Type | Automated Command | File Exists? |
|-----|----------|-----------|-------------------|-------------|
| D-11 | Duplicate alias / core-type clash / bad element tag fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeRegistry` | ❌ Wave 0 |
| D-12.1 | Unknown plugin key and CheckOptions error fail boot | unit | `go test -short ./modules/cabana -run TestFieldTypeOptions` | ❌ |
| D-12.2/D-10 | FieldError → 422 on field, default + non-default locale | unit (DryRun/fake writer) | `go test -short ./modules/cabana -run TestFieldTypeDecode` | ❌ |
| D-03 | Submitted == served → nothing written (host and translation) | unit | `go test -short ./modules/cabana -run TestFieldTypeUntouched` | ❌ |
| D-12.3/D-07 | Load serves `content_html` for a markdown post, `content` for HTML | unit | `go test -short ../sm-journal-plugin/classes -run TestBlogEditorLoad` | ❌ |
| D-12.4 | List column with plugin type uses ListValue | unit | `go test -short ./modules/cabana -run TestFieldTypeListValue` | ❌ |
| D-13/D-15 | Schema carries `element`/`translatable`; OpenAPI/TS in sync | unit + script | `go test ./modules/cabana -run 'TestPhase09' && bash scripts/check-admin-openapi.sh --check` | ✅ (extend) |
| D-13 | PluginField binds value, emits only on summer-change; ML wrapper | vitest | `npm --prefix admin test -- PluginField` | ❌ |
| D-18 | Upload route: auth, CSRF, permission, scope, size cap, non-plugin field 404 | unit | `go test -short ./modules/cabana -run TestFieldUpload` | ❌ |
| D-03/D-04 | Element: untouched value identical; rawHtml/embed/figure/link/table round-trip | vitest (happy-dom) | `npm --prefix ../sm-journal-plugin test` | ❌ |
| todo resave | Re-save untouched imported HTML post: `content` and `content_html` byte-identical; edited save sets both equal | integration (Postgres) | `go test ../sm-journal-plugin -run TestPostsAdminResaveKeepsHTML` (Docker host) | ✅ (tighten) |
| D-17 | expandEmbeds allowlist, id regex, gate-first order | node:test | `npm --prefix ../vue-journal-layer test` | ❌ (`tests/embeds.test.ts`) |
| docs | READMEs/docs identifiers and links | unit | `go test ./cmd/summer -run TestDocsTree` | ✅ |
| dist | Admin and plugin bundles match committed dist | script | `bash scripts/check-admin-dist.sh` and `bash ../sm-journal-plugin/scripts/check-dist.sh` | ✅ / ❌ |
### Sampling Rate
- **Per task commit:** `go vet ./... && go test -short` for the touched packages, plus the touched vitest file.
- **Per wave merge:** every full-suite command above.
- **Phase gate:** full suites green, `check-admin-dist.sh`, `check-admin-openapi.sh --check`, the plugin `check-dist.sh`, `TestDocsTree`, and the human Docker-host run of the testcontainers suites, all before `/msd-verify-work`.
### Wave 0 Gaps
- [ ] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, `vitest` setup with happy-dom, `scripts/check-dist.sh`
- [ ] `modules/cabana/field_type_test.go`: registry, options, decode/skip, list, upload (reuse `openDryRunDB`/`newDryRunListService` from query_relation_test.go and a fake `TranslationWriter`)
- [ ] `admin/tests/form/PluginField.test.ts` with a stub custom element
- [ ] `../vue-journal-layer/tests/embeds.test.ts`
## Security Domain
`security_enforcement` is absent from config (treated as enabled); ASVS L1.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (upload route) | cabana backend guard (cookie or Bearer); never a token handed to the element |
| V3 Session Management | yes | Existing HttpOnly, SameSite=Strict cookie scoped to the admin prefix; `requireAjax` on POST |
| V4 Access Control | yes | Controller `RequiredPermissions` + `FormExtendQuery` record scope on the upload route; field must be a plugin type with `Upload` and its `context` must allow the op |
| V5 Input Validation | yes | `CheckHTML` per locale in Decode (refuse, no rewrite); option keys validated at boot; upload extension allowlist + size cap; embed id regex in the layer |
| V6 Cryptography | no | — |
| V12 Files and Resources | yes | `.jpg/.jpeg/.png/.gif/.webp` only (no SVG); 10 MiB cap; nginx serves `/storage/app/uploads/public` image types only with `nosniff` [VERIFIED: deploy/nginx/grzybyfunkcjonalne-staging.conf, its storage image location block] |
| V14 Configuration | yes | Admin CSP `script-src 'self'` unchanged; bundle has no eval |
### Known Threat Patterns (threat model input)
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Stored XSS through the raw HTML block, source tab or a non-default locale | Tampering / Elevation | `CheckHTML` in Decode for every locale value; layer `gateJournalHtml` again before v-html |
| An iframe smuggled in as an "embed" | Tampering | Placeholder only in storage; the layer builds the iframe from a validated id and a fixed host |
| XSS in the admin itself (the element renders stored HTML in contenteditable) | Elevation | ProseMirror renders through its schema, so `script` is never a node and the `rawHtml` card shows text, not live DOM. The admin CSP `script-src 'self'` blocks inline handlers. Do not `innerHTML` raw blocks into the live admin DOM. [ASSUMED: verify in the plan's tests that the rawHtml card renders escaped text] |
| A plugin alias hijacking a core type or another plugin's type | Spoofing | Boot fails on any collision (D-11) |
| A plugin element exfiltrating the session | Information disclosure | No credential is passed to elements; the cookie is HttpOnly; the SPA performs uploads |
| CSRF on the upload route | Tampering | `requireAjax` (X-Requested-With) plus a SameSite=Strict cookie |
| Upload of HTML or SVG disguised as an image | Tampering / Elevation | Extension allowlist without SVG; nginx type map + `nosniff`; consider also checking the magic bytes with `http.DetectContentType` (stdlib) |
| Path traversal or odd filenames in upload keys | Tampering | `path.Base`, the folder regex `^[A-Za-z0-9_\-/]*$`, `..` refused (existing); consider slugifying the base name for the admin path |
| Oversized uploads, or unbounded JSON in Decode | DoS | `http.body_limits.upload_bytes` on the route; the existing default body limit on saves |
| Option values reaching the element as HTML | Tampering | Options delivered as a JSON attribute; the element never `innerHTML`s them |
## Sources
### Primary (HIGH confidence)
- Source files read in this session:
- `modules/pact/capabilities.go`
- in `modules/cabana`: `form_schema.go`, `crud.go`, `field_ml.go`, `extension.go`, `plugin_assets.go`, `registry.go`, `http.go`, `list_schema.go`, `schema_types.go`
- in `admin/src`: `components/form/registry.ts`, `formState.ts`, `mlLocale.ts`, `fields/WidgetField.vue`, `fields/MLField.vue`, `app/pluginAssets.ts`, `views/FormView.vue`
- `modules/boardwalk/boardwalk.go`, `modules/bouncer/jwt.go`
- in sm-journal-plugin: `classes/format_html.go`, `controllers/posts.go`, `controllers/categories.go`, `controllers/api/media.go`, `controllers/api/auth.go`, `routes.go`, `admin.go`, `plugin.go`, `models/post.go`, `models/category.go`, both `fields.yaml`, `posts_admin_smoke_test.go`
- in sm-translate-plugin: `classes/admin_writer.go`
- in vue-journal-layer: `PostBody.vue`, `htmlGate.ts`
- in the host: nginx conf, `DEPLOY.md`, `storage.yaml`
- in the frozen PHP Journal: `Category.php`, `Post.php`, `JournalMarkdown.php`, `MLJournalMarkdown.php`
- npm registry: `npm view` for every recommended package; `msd-tools query package-legitimacy check`
- Local probes: TipTap 3.31.4 on happy-dom 20.11.6 (lossiness, rawHtml/embed nodes, global attributes, emitUpdate, trailingNode); an esbuild bundle-size build
### Secondary (MEDIUM confidence)
- tiptap.dev docs: extend-existing (global attributes), editor API (parseOptions, injectCSS), set-content (emitUpdate default true), table (TableKit options)
- support.google.com/youtube/answer/171780 (privacy-enhanced embed domain)
### Tertiary (LOW confidence)
- General ProseMirror Shadow DOM quirk claim (A2)
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH. Versions come from the registry, and the toolchain is pinned to the admin's existing lockfile.
- Architecture: HIGH for the integration points (read from source). MEDIUM for the proposed API shape and the editor serializer, which are design choices.
- Pitfalls: HIGH. Most were reproduced by local probes or read from source.
**Research date:** 2026-10-09
**Valid until:** 2026-11-08 for the framework findings. Re-check TipTap patch versions before installing, because the project releases fast.