- config_form.yaml preview block (optional headerPartial), reported in the form schema as preview
- fields with context: preview show only on the preview screen and are never written
- form messages preview and edit; recordActions without a preview block stops boot
- SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer
- mapWinterUrl maps preview/:id; the update form returns to the preview
- summer-callout partial style classes for status hints
- README, docs, OpenAPI document, TS types and the embedded build updated
367 lines
13 KiB
Go
367 lines
13 KiB
Go
package cabana
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/boardwalk"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// widgetTagPattern is a valid custom-element name restricted to lowercase
|
|
// ASCII: a letter, then at least one hyphenated segment.
|
|
var widgetTagPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)+$`)
|
|
|
|
// reservedWidgetTags are the hyphenated names the HTML specification reserves;
|
|
// customElements.define refuses them.
|
|
var reservedWidgetTags = map[string]bool{
|
|
"annotation-xml": true, "color-profile": true, "font-face": true, "font-face-src": true,
|
|
"font-face-uri": true, "font-face-format": true, "font-face-name": true, "missing-glyph": true,
|
|
}
|
|
|
|
// assetSegment is one segment of the plugin ID in an asset URL.
|
|
var assetSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
|
|
// pluginAsset is one declared controller JS or CSS file, read from the
|
|
// plugin's embedded tree and hashed at boot. The asset route serves only
|
|
// these exact keys (D-13, D-15, D-16).
|
|
type pluginAsset struct {
|
|
// key is vendor/plugin/<path after assets/>, the URL tail under
|
|
// {prefix}/assets/.
|
|
key string
|
|
body []byte
|
|
contentType string
|
|
// etag is the quoted hex sha256 of body; version is its first 12 hex
|
|
// characters, used as the ?v= cache buster.
|
|
etag string
|
|
version string
|
|
}
|
|
|
|
// builtinToolbarActions are the toolbar actions the framework implements
|
|
// itself (D-14); a controller may not register an action with these names.
|
|
var builtinToolbarActions = map[string]bool{"create": true, "delete": true}
|
|
|
|
// widgetTagPrefix is the custom-element prefix a plugin's widgets must use:
|
|
// the plugin ID lowercased with dots and underscores turned into hyphens,
|
|
// plus a trailing hyphen (acme.conform -> "acme-conform-"). It keeps two
|
|
// plugins from defining the same element.
|
|
func widgetTagPrefix(pluginID string) string {
|
|
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
|
|
}
|
|
|
|
// compileExtension validates a controller's runtime admin extension points
|
|
// after its list and form are compiled and its writable fields are bound: the
|
|
// registered actions and every `type: widget` field. Every failure stops boot.
|
|
func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
|
if cc == nil || cc.Controller == nil {
|
|
return nil
|
|
}
|
|
id := cc.Controller.ID()
|
|
actions, err := compileActions(cc.Controller)
|
|
if err != nil {
|
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
|
}
|
|
cc.Actions = actions
|
|
bulkActions, err := compileBulkActions(cc.Controller)
|
|
if err != nil {
|
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
|
}
|
|
cc.BulkActions = bulkActions
|
|
recordActions, err := compileRecordActions(cc.Controller)
|
|
if err != nil {
|
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
|
}
|
|
cc.RecordActions = recordActions
|
|
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
|
|
return err
|
|
}
|
|
if err := compilePartials(pluginID, cc, fsys); err != nil {
|
|
return err
|
|
}
|
|
if cc.Form == nil {
|
|
return nil
|
|
}
|
|
// Every recordActions name of config_form.yaml must be a record action
|
|
// the controller registers, with a label (D-10).
|
|
formFile := cc.Form.configPath
|
|
if formFile == "" {
|
|
formFile = "config_form.yaml"
|
|
}
|
|
// Record actions are offered on the preview screen only (D-10, D-11).
|
|
if len(cc.Form.recordActions) > 0 && cc.Form.preview == nil {
|
|
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions needs a preview block (record actions are offered on the preview screen)"))
|
|
}
|
|
for _, name := range cc.Form.recordActions {
|
|
action, ok := recordActions[name]
|
|
if !ok {
|
|
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: unsupported action %s (want a record action the controller registers)", name))
|
|
}
|
|
if strings.TrimSpace(action.Label) == "" {
|
|
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: action %s needs a label", name))
|
|
}
|
|
}
|
|
file := cc.Form.fieldsPath
|
|
if file == "" {
|
|
file = "fields.yaml"
|
|
}
|
|
fields := map[string]FormField{}
|
|
for _, field := range cc.Form.Fields {
|
|
fields[field.Name] = field
|
|
}
|
|
writable := map[string]bool{}
|
|
for _, field := range cc.Writable {
|
|
writable[field.Name] = true
|
|
}
|
|
prefix := widgetTagPrefix(pluginID)
|
|
for i := range cc.Form.Fields {
|
|
field := &cc.Form.Fields[i]
|
|
if field.Type != "widget" {
|
|
continue
|
|
}
|
|
if err := checkWidgetTag(field.Widget, prefix); err != nil {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: %w", field.Name, err))
|
|
}
|
|
action, ok := actions[field.Action]
|
|
if !ok {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: action %s is not registered by the controller (pact.HasAdminActions)", field.Name, field.Action))
|
|
}
|
|
for _, key := range field.Fill {
|
|
target, exists := fields[key]
|
|
if !exists {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a field of this form", field.Name, key))
|
|
}
|
|
if !scalarFormField(target.Type) || !writable[key] {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a writable scalar field", field.Name, key))
|
|
}
|
|
}
|
|
field.ActionLabel = action.Label
|
|
if len(cc.scripts) == 0 {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("field %s: a widget needs the controller to declare its JS through pact.AdminClientAssets", field.Name))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compilePartials reads and parses every partial the controller declares:
|
|
// config_list.yaml headerPartial, config_form.yaml preview.headerPartial and
|
|
// each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm.
|
|
// The preview header partial is a form partial: the partial route renders it
|
|
// with a record id, loaded through the form scope. A missing or unparsable template, or a
|
|
// controller without pact.AdminPartialData, fails boot (D-11).
|
|
func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
|
id := cc.Controller.ID()
|
|
var names []string
|
|
formNames := map[string]bool{}
|
|
if cc.List != nil && cc.List.HeaderPartial != "" {
|
|
names = append(names, cc.List.HeaderPartial)
|
|
}
|
|
if cc.Form != nil {
|
|
if cc.Form.preview != nil && cc.Form.preview.HeaderPartial != "" {
|
|
names = append(names, cc.Form.preview.HeaderPartial)
|
|
formNames[cc.Form.preview.HeaderPartial] = true
|
|
}
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Type == "partial" {
|
|
names = append(names, field.Path)
|
|
formNames[field.Path] = true
|
|
}
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
dir := strings.Trim(path.Clean(cc.Controller.ConfigDir()), "/")
|
|
if dir == "." || strings.HasPrefix(dir, "..") {
|
|
return bootErr(pluginID, id, cc.Controller.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
|
|
}
|
|
partials := map[string]*compiledPartial{}
|
|
for _, name := range names {
|
|
if _, done := partials[name]; done {
|
|
continue
|
|
}
|
|
file := path.Join(dir, "_"+name+".htm")
|
|
if provider, ok := cc.Controller.(pact.AdminPartialData); !ok || provider == nil {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("partial %s needs the controller to implement pact.AdminPartialData", name))
|
|
}
|
|
src, err := readAsset(fsys, file)
|
|
if err != nil {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("partial %s: template is not in the plugin's embedded files: %w", name, err))
|
|
}
|
|
compiled, err := parsePartial(name, src)
|
|
if err != nil {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("partial %s: %w", name, err))
|
|
}
|
|
partials[name] = compiled
|
|
}
|
|
cc.partials, cc.formPartials = partials, formNames
|
|
return nil
|
|
}
|
|
|
|
// compileClientAssets reads and hashes the files a controller declares
|
|
// through pact.AdminClientAssets. Each path must be clean, live under
|
|
// assets/, carry a JS (.js, .mjs) or CSS (.css) extension and exist in the
|
|
// plugin's embedded tree; there is no disk override.
|
|
func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
|
src, ok := cc.Controller.(pact.AdminClientAssets)
|
|
if !ok || src == nil {
|
|
return nil
|
|
}
|
|
id := cc.Controller.ID()
|
|
vendor, plugin, found := strings.Cut(pluginID, ".")
|
|
if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) {
|
|
return fmt.Errorf("cabana: admin controller %s/%s: plugin ID must be vendor.plugin to serve admin assets", pluginID, id)
|
|
}
|
|
read := func(files []string, exts ...string) ([]*pluginAsset, error) {
|
|
out := make([]*pluginAsset, 0, len(files))
|
|
seen := map[string]bool{}
|
|
for _, name := range files {
|
|
if err := checkAssetPath(name, exts); err != nil {
|
|
return nil, bootErr(pluginID, id, name, err)
|
|
}
|
|
if seen[name] {
|
|
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset declared twice"))
|
|
}
|
|
seen[name] = true
|
|
body, err := fs.ReadFile(fsys, name)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err))
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
digest := hex.EncodeToString(sum[:])
|
|
out = append(out, &pluginAsset{
|
|
key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"),
|
|
body: body,
|
|
contentType: boardwalk.ContentType(name),
|
|
etag: `"` + digest + `"`,
|
|
version: digest[:12],
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
scripts, err := read(src.AdminJS(), ".js", ".mjs")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
styles, err := read(src.AdminCSS(), ".css")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cc.scripts, cc.styles = scripts, styles
|
|
return nil
|
|
}
|
|
|
|
func checkAssetPath(name string, exts []string) error {
|
|
if name != path.Clean(name) || !strings.HasPrefix(name, "assets/") || len(name) == len("assets/") {
|
|
return fmt.Errorf("asset path must be a clean path under assets/")
|
|
}
|
|
for _, segment := range strings.Split(name, "/") {
|
|
if segment == ".." || segment == "" {
|
|
return fmt.Errorf("asset path must be a clean path under assets/")
|
|
}
|
|
}
|
|
ext := strings.ToLower(path.Ext(name))
|
|
for _, want := range exts {
|
|
if ext == want {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("asset must end in %s", strings.Join(exts, " or "))
|
|
}
|
|
|
|
func checkWidgetTag(tag, prefix string) error {
|
|
if !widgetTagPattern.MatchString(tag) {
|
|
return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", tag)
|
|
}
|
|
if reservedWidgetTags[tag] {
|
|
return fmt.Errorf("widget %q is a reserved element name", tag)
|
|
}
|
|
if !strings.HasPrefix(tag, prefix) {
|
|
return fmt.Errorf("widget %q must start with the plugin prefix %q", tag, prefix)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compileActions collects a controller's registered actions into the single
|
|
// action namespace (assumption-delta decision: create and delete are reserved).
|
|
func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, error) {
|
|
out := map[string]pact.AdminAction{}
|
|
src, ok := ctl.(pact.HasAdminActions)
|
|
if !ok || src == nil {
|
|
return out, nil
|
|
}
|
|
for _, action := range src.AdminActions() {
|
|
if !identifier(action.Name) {
|
|
return nil, fmt.Errorf("action name %q is not an identifier", action.Name)
|
|
}
|
|
if builtinToolbarActions[action.Name] {
|
|
return nil, fmt.Errorf("action %s uses a reserved built-in name (create, delete)", action.Name)
|
|
}
|
|
if _, dup := out[action.Name]; dup {
|
|
return nil, fmt.Errorf("duplicate action %s", action.Name)
|
|
}
|
|
if action.Run == nil {
|
|
return nil, fmt.Errorf("action %s has no Run function", action.Name)
|
|
}
|
|
out[action.Name] = action
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// compileBulkActions collects a controller's registered bulk actions. They
|
|
// have their own namespace next to the toolbar and widget actions: a name is
|
|
// unique among the bulk actions, and create and delete stay reserved.
|
|
func compileBulkActions(ctl pact.AdminController) (map[string]pact.AdminBulkAction, error) {
|
|
out := map[string]pact.AdminBulkAction{}
|
|
src, ok := ctl.(pact.HasAdminBulkActions)
|
|
if !ok || src == nil {
|
|
return out, nil
|
|
}
|
|
for _, action := range src.AdminBulkActions() {
|
|
if !identifier(action.Name) {
|
|
return nil, fmt.Errorf("bulk action name %q is not an identifier", action.Name)
|
|
}
|
|
if builtinToolbarActions[action.Name] {
|
|
return nil, fmt.Errorf("bulk action %s uses a reserved built-in name (create, delete)", action.Name)
|
|
}
|
|
if _, dup := out[action.Name]; dup {
|
|
return nil, fmt.Errorf("duplicate bulk action %s", action.Name)
|
|
}
|
|
if action.Run == nil {
|
|
return nil, fmt.Errorf("bulk action %s has no Run function", action.Name)
|
|
}
|
|
out[action.Name] = action
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// compileRecordActions collects a controller's registered record actions into
|
|
// their own namespace; create and delete stay reserved.
|
|
func compileRecordActions(ctl pact.AdminController) (map[string]pact.AdminRecordAction, error) {
|
|
out := map[string]pact.AdminRecordAction{}
|
|
src, ok := ctl.(pact.HasAdminRecordActions)
|
|
if !ok || src == nil {
|
|
return out, nil
|
|
}
|
|
for _, action := range src.AdminRecordActions() {
|
|
if !identifier(action.Name) {
|
|
return nil, fmt.Errorf("record action name %q is not an identifier", action.Name)
|
|
}
|
|
if builtinToolbarActions[action.Name] {
|
|
return nil, fmt.Errorf("record action %s uses a reserved built-in name (create, delete)", action.Name)
|
|
}
|
|
if _, dup := out[action.Name]; dup {
|
|
return nil, fmt.Errorf("duplicate record action %s", action.Name)
|
|
}
|
|
if action.Run == nil {
|
|
return nil, fmt.Errorf("record action %s has no Run function", action.Name)
|
|
}
|
|
out[action.Name] = action
|
|
}
|
|
return out, nil
|
|
}
|