Backend

Partials and widgets

Extend admin screens with server-rendered partials, plugin JavaScript and CSS, form widgets backed by server actions, and custom toolbar buttons.

On this page

WinterCMS controllers extend their screens with partials, addJs and addCss, custom form widgets and toolbar buttons that call AJAX handlers. The SummerCMS admin is a single-page app, so cabana keeps these extension points in a form the SPA can render safely: partials arrive as an allowlisted node tree, plugin scripts are declared files, and every button or widget runs a server action through a cabana-owned route.

Server-rendered partials#

Two places accept a partial:

  • headerPartial: <name> in config_list.yaml, a strip above the list;
  • a type: partial field with path: <name> in fields.yaml.

Both render {ConfigDir}/_<name>.htm with Go's html/template. WinterCMS $/ and ~/ partial paths are not supported. The template's data is .Data, the value the controller's pact.AdminPartialData returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's pact.FormExtendQuery scope. trans "<key>" translates a phrase key in the request locale.

A statistics strip above a list, using the SPA's partial style classes:

modules/cabana/testdata/extension/controllers/gadgets/_stats.htm
<dl class="summer-stats">
{{- range .Data.Items -}}
<div class="summer-stat"><dt class="summer-stat__label">{{ trans .Label }}</dt><dd class="summer-stat__value">{{ .Count }}</dd></div>
{{- end -}}
</dl>

The view model must be a struct built for the template. cabana refuses a view model that holds the controller's model or any other GORM model, anywhere inside it, and refuses pre-escaped html/template content types, so every record value stays escaped.

The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, id, style and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes.

Plugin JavaScript and CSS#

A controller that implements pact.AdminClientAssets names .js, .mjs and .css files under its plugin's assets/ directory, the Go form of addJs and addCss. They are read from the embedded tree at start-up (a missing file stops it) and served from <prefix>/assets/{vendor}/{plugin}/... with a content hash in the URL, the admin Content-Security-Policy (script-src 'self') and nosniff. Only declared files are reachable; the YAML and templates never are.

Plugin CSS may use only the SPA's public CSS variables (--c-bg, --c-surface, --c-text, --c-primary and the others the cabana README lists), which switch with dark mode. Do not hardcode colours and do not rely on the SPA's utility classes.

Form widgets#

A type: widget field puts a plugin custom element in the form and connects it to a server action:

lookup:
    label: acme.blog::lang.posts.lookup
    type: widget
    widget: acme-blog-lookup
    action: lookup
    fill: [title, slug]
  • widget is the custom element's tag, which must start with the plugin's {vendor}-{plugin}- prefix. A plugin script declared through pact.AdminClientAssets defines the element.
  • action names an action the controller registers through pact.HasAdminActions.
  • fill lists the writable scalar fields of the same form the action may write back.

The SPA posts the widget's values to .../widgets/{field}. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's Run with a pact.AdminActionInput. The answer's pact.AdminActionResult carries a message and the fill values; keys outside fill and values that are not scalars are dropped before the response is written. An action may return a cabana.ValidationError to answer 422 on a field.

Toolbar actions#

Names in toolbar.buttons of config_list.yaml, other than the built-in create and delete, are actions the controller registers through pact.HasAdminActions. Each needs a label. A toolbar action runs with an empty body and no record IDs, so it can never become an unscoped lookup of IDs the client chose. The list schema lists only the actions the administrator may run.

An unknown action name, a widget tag outside the plugin's prefix or a fill key that is not a writable scalar field stops the start-up.