feat(17-01): richeditor field type on the seaglass sanitizer

- seaglass (new module): bluemonday v1.0.27 policy ported from Journal
  without the Blog Editor rules, byte-stable token comparison,
  SanitizeReport, Sanitize, Check, Removals
- cabana: type richeditor (common keys plus size); every create and update
  value sanitized before Fill, non-string values are a 422, removals become
  meta.notices; settings and relation forms refuse the type
- backend lang richeditor.removed_* and markup_changed (en, pl plurals)
- TestSeaglassSmoke, TestCompanionsRichEditorSmoke, README and docs
This commit is contained in:
2026-10-12 15:15:38 +02:00
parent 69bb1cc95d
commit 71fa597830
16 changed files with 811 additions and 8 deletions

View File

@@ -101,8 +101,9 @@ for _, f := range form.Fields {
| `mltext` | A text field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
| `mltextarea` | A multi-line text field with one editor per locale; it takes `size` as `textarea` does. See [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
| `richeditor` | A WYSIWYG HTML editor for a host text column, sanitized on every save; see [Rich text](#rich-text). |
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
### Field options
@@ -391,6 +392,25 @@ fields:
A save of `{ "title": { "en": "Hello", "pl": "Witaj" } }` lifts the nested map before `cabana.ProjectWritableFields` drops nested values. The default locale fills the host column; other locales are written through a published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. Relation-child create and update on `cabana.RelationService` use the same lift and apply path as controller save; child show hydrates through `hydrateMLRecord` like a host GET. Show and save then run `hydrateMLRecord`: each declared ML field in the record payload is a map of every enabled locale (default from the host column, others from `TranslationWriter.TranslatedExact`; a missing non-default code is an empty string, never D-11 fallback). A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write or translate-read route: if no writer is published, a nested ML map is a 422.
## Rich text
`type: richeditor` edits HTML in a host text column with a WYSIWYG editor in the admin SPA. It takes the common keys (`label`, `comment`, `span`, `required`, `tab`, `context`, `default`) plus `size`; any other key stops the start-up.
```yaml
fields:
description:
label: acme.faq::lang.faq.description
type: richeditor
size: large
```
The value travels as a string of HTML, or null. Anything else in a save body is a 422 on the field. Every submitted value is sanitized on the server before it is stored, on create and on update, through [seaglass](../../modules/seaglass/README.md): bluemonday's UGC policy, which keeps paragraphs, headings, lists, emphasis, quotes, tables, images and links with `http`, `https`, `mailto`, `tel` or relative URLs, and removes scripts, styles, frames, forms, comments, every `on*` attribute and every other URL scheme.
- HTML the policy keeps whole is stored byte for byte, so a value that only differs in escaping is never rewritten.
- Otherwise the clean HTML is stored and the save still succeeds. The create or update response then carries `meta.notices` for the field: how many elements and attributes were removed with their names, how many comments, or that some markup changed. The admin shows them under the field.
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up, so no save path stores an unsanitized value.
## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.