diff --git a/cabana/contracts.go b/cabana/contracts.go index 9c040fc..9fb29f3 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -78,6 +78,7 @@ type CompiledController struct { PluginID string Controller pact.AdminController List *ListSchema + Form *FormSchema } // Registry is the immutable controller map keyed by controller ID. diff --git a/cabana/form_schema.go b/cabana/form_schema.go index 013d387..5ca008b 100644 --- a/cabana/form_schema.go +++ b/cabana/form_schema.go @@ -1,28 +1,471 @@ package cabana import ( + "bytes" + "encoding/json" + "errors" "fmt" "io/fs" + "path" + "strconv" + "strings" "git.golem15.com/golem15/summercms/pact" + "github.com/goccy/go-yaml" + "github.com/goccy/go-yaml/ast" ) -// FormSchema is the boot-compiled form contract. The RED stub rejects every -// asset so the ADMIN-01 tests fail on the contract, not on a missing symbol. -type FormSchema struct{} - -// CompileForm will compile config_form.yaml and fields.yaml. It does not yet. -func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { - if pluginID == "" || ctl == nil || fsys == nil { - return nil, fmt.Errorf("cabana: form schema is not implemented") +var ( + formFieldTypes = map[string]struct{}{ + "text": {}, "textarea": {}, "number": {}, "checkbox": {}, + "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, } - return nil, fmt.Errorf("cabana: form schema is not implemented") + formSpans = map[string]struct{}{ + "left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, + } + formSizes = map[string]struct{}{ + "tiny": {}, "small": {}, "large": {}, "huge": {}, "giant": {}, + } + formFieldKeys = map[string]struct{}{ + "label": {}, "comment": {}, "span": {}, "type": {}, "required": {}, + "tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {}, + "nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {}, + } +) + +type formConfigDocument struct { + Name string `yaml:"name"` + Form string `yaml:"form"` + ModelClass string `yaml:"modelClass"` + DefaultRedirect string `yaml:"defaultRedirect"` + Create *formRedirects `yaml:"create"` + Update *formRedirects `yaml:"update"` } -// CanonicalJSON will emit the stable form contract. It does not yet. +type formRedirects struct { + Redirect string `yaml:"redirect"` + RedirectClose string `yaml:"redirectClose"` +} + +type formFieldsFile struct { + Fields fieldMap `yaml:"fields"` +} + +type fieldMap struct { + items []FormField +} + +// CompileForm compiles config_form.yaml and the model fields.yaml it names. +// The returned schema is safe to cache: labels stay as source keys. +func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { + if ctl == nil { + return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID) + } + dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") + if dir == "." || strings.HasPrefix(dir, "..") { + return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) + } + cfgPath := dir + "/config_form.yaml" + raw, err := readAsset(fsys, cfgPath) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + var doc formConfigDocument + if err := decodeStrict(raw, &doc); err != nil { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + if doc.ModelClass != ctl.ModelName() { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName())) + } + if strings.TrimSpace(doc.Form) == "" { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("form file is empty")) + } + fieldsPath, err := assetPath(pluginID, doc.Form) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + fieldsRaw, err := readAsset(fsys, fieldsPath) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) + } + fields, err := decodeFields(fieldsRaw) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) + } + if fields == nil { + fields = []FormField{} + } + return &FormSchema{ + Name: doc.Name, + ModelClass: doc.ModelClass, + Fields: fields, + }, nil +} + +// CanonicalJSON is the stable, locale-neutral form contract. func (s *FormSchema) CanonicalJSON() ([]byte, error) { if s == nil { - return nil, fmt.Errorf("cabana: form schema is not implemented") + return nil, fmt.Errorf("cabana: nil form schema") } - return nil, fmt.Errorf("cabana: form schema is not implemented") + out := *s + if out.Fields == nil { + out.Fields = []FormField{} + } + return json.Marshal(out) +} + +func decodeFields(raw []byte) ([]FormField, error) { + dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) + var doc formFieldsFile + if err := dec.Decode(&doc); err != nil { + return nil, normalizeYAMLError(err) + } + if doc.Fields.items == nil { + return []FormField{}, nil + } + return doc.Fields.items, nil +} + +func (m *fieldMap) UnmarshalYAML(node ast.Node) error { + node = unwrapNode(node) + if _, ok := node.(*ast.NullNode); ok || node == nil { + m.items = []FormField{} + return nil + } + mapping, ok := node.(*ast.MappingNode) + if !ok { + return fmt.Errorf("fields must be a mapping") + } + items := make([]FormField, 0, len(mapping.Values)) + seen := map[string]struct{}{} + for _, entry := range mapping.Values { + name, err := nodeString(unwrapNode(entry.Key)) + if err != nil || !identifier(name) { + return fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key)) + } + if _, dup := seen[name]; dup { + return fmt.Errorf("duplicate field %s", name) + } + seen[name] = struct{}{} + field, err := compileFieldNode(name, unwrapNode(entry.Value)) + if err != nil { + return fmt.Errorf("field %s: %w", name, err) + } + items = append(items, field) + } + m.items = items + return nil +} + +func compileFieldNode(name string, node ast.Node) (FormField, error) { + mapping, ok := node.(*ast.MappingNode) + if !ok { + return FormField{}, fmt.Errorf("must be a mapping") + } + values := map[string]ast.Node{} + for _, entry := range mapping.Values { + key, err := nodeString(unwrapNode(entry.Key)) + if err != nil { + return FormField{}, err + } + if _, known := formFieldKeys[key]; !known { + return FormField{}, fmt.Errorf("unknown field %s", key) + } + if _, dup := values[key]; dup { + return FormField{}, fmt.Errorf("duplicate field %s", key) + } + values[key] = unwrapNode(entry.Value) + } + typ, err := nodeString(values["type"]) + if err != nil || typ == "" { + return FormField{}, fmt.Errorf("type is required") + } + if typ == "partial" { + return FormField{}, fmt.Errorf("type partial is not supported") + } + if _, ok := formFieldTypes[typ]; !ok { + return FormField{}, fmt.Errorf("unsupported type %s", typ) + } + field := FormField{Name: name, Type: typ} + if node, ok := values["label"]; ok { + field.Label, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("label: %w", err) + } + } + if node, ok := values["comment"]; ok { + field.Comment, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("comment: %w", err) + } + } + if node, ok := values["span"]; ok { + field.Span, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("span: %w", err) + } + if _, ok := formSpans[field.Span]; !ok { + return FormField{}, fmt.Errorf("unsupported span %s", field.Span) + } + } + if node, ok := values["tab"]; ok { + field.Tab, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("tab: %w", err) + } + } + if node, ok := values["size"]; ok { + field.Size, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("size: %w", err) + } + if _, ok := formSizes[field.Size]; !ok { + return FormField{}, fmt.Errorf("unsupported size %s", field.Size) + } + } + if node, ok := values["context"]; ok { + ctx, err := compileContext(node) + if err != nil { + return FormField{}, err + } + field.Context = ctx + } + if node, ok := values["nameFrom"]; ok { + field.NameFrom, err = nodeString(node) + if err != nil || !identifier(field.NameFrom) { + return FormField{}, fmt.Errorf("nameFrom %q is not an identifier", field.NameFrom) + } + } + if node, ok := values["emptyOption"]; ok { + field.EmptyOption, err = nodeString(node) + if err != nil { + return FormField{}, fmt.Errorf("emptyOption: %w", err) + } + } + if node, ok := values["relation"]; ok { + field.Relation, err = nodeString(node) + if err != nil || !identifier(field.Relation) { + return FormField{}, fmt.Errorf("relation %q is not an identifier", field.Relation) + } + } + if typ == "relation-manager" && field.Relation == "" { + return FormField{}, fmt.Errorf("relation is required") + } + if node, ok := values["required"]; ok { + field.Required, err = nodeBool(node) + if err != nil { + return FormField{}, fmt.Errorf("required: %w", err) + } + } + if node, ok := values["default"]; ok { + scalar, err := nodeScalar(node) + if err != nil { + return FormField{}, fmt.Errorf("default: %w", err) + } + field.Default = &scalar + } + if node, ok := values["attributes"]; ok { + attrs, err := compileAttributes(node) + if err != nil { + return FormField{}, fmt.Errorf("attributes: %w", err) + } + field.Attributes = attrs + } + if node, ok := values["options"]; ok { + if err := compileOptions(node, &field); err != nil { + return FormField{}, fmt.Errorf("options: %w", err) + } + } + return field, nil +} + +func compileContext(node ast.Node) (*fieldContext, error) { + switch n := node.(type) { + case *ast.StringNode: + if !identifier(n.Value) { + return nil, fmt.Errorf("context %q is not an identifier", n.Value) + } + return &fieldContext{single: true, values: []string{n.Value}}, nil + case *ast.SequenceNode: + values := sequenceValues(n) + out := make([]string, 0, len(values)) + for _, item := range values { + text, err := nodeString(unwrapNode(item)) + if err != nil || !identifier(text) { + return nil, fmt.Errorf("context %q is not an identifier", nodeText(item)) + } + out = append(out, text) + } + return &fieldContext{values: out}, nil + default: + return nil, fmt.Errorf("context must be a string or a list") + } +} + +func compileAttributes(node ast.Node) (map[string]jsonScalar, error) { + mapping, ok := node.(*ast.MappingNode) + if !ok { + return nil, fmt.Errorf("must be a mapping") + } + out := make(map[string]jsonScalar, len(mapping.Values)) + for _, entry := range mapping.Values { + key, err := nodeString(unwrapNode(entry.Key)) + if err != nil || !identifier(key) { + return nil, fmt.Errorf("key %q is not an identifier", nodeText(entry.Key)) + } + scalar, err := nodeScalar(unwrapNode(entry.Value)) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + out[key] = scalar + } + return out, nil +} + +func compileOptions(node ast.Node, field *FormField) error { + switch n := node.(type) { + case *ast.StringNode: + if !identifier(n.Value) { + return fmt.Errorf("method %q is not an identifier", n.Value) + } + field.optionsMethod = n.Value + return nil + case *ast.MappingNode: + options := make([]FormOption, 0, len(n.Values)) + for _, entry := range n.Values { + value, err := nodeScalar(unwrapNode(entry.Key)) + if err != nil { + return fmt.Errorf("value: %w", err) + } + label, err := nodeString(unwrapNode(entry.Value)) + if err != nil { + return fmt.Errorf("label: %w", err) + } + options = append(options, FormOption{Value: value, Label: label}) + } + field.Options = options + return nil + default: + return fmt.Errorf("must be a map or a method name") + } +} + +func sequenceValues(n *ast.SequenceNode) []ast.Node { + if len(n.Values) > 0 { + return n.Values + } + out := make([]ast.Node, 0, len(n.Entries)) + for _, entry := range n.Entries { + if entry != nil && entry.Value != nil { + out = append(out, entry.Value) + } + } + return out +} + +func unwrapNode(node ast.Node) ast.Node { + for node != nil { + switch n := node.(type) { + case *ast.MappingKeyNode: + node = n.Value + case *ast.TagNode: + node = n.Value + case *ast.AnchorNode: + node = n.Value + default: + return node + } + } + return nil +} + +func nodeString(node ast.Node) (string, error) { + switch n := unwrapNode(node).(type) { + case *ast.StringNode: + return n.Value, nil + case nil: + return "", fmt.Errorf("missing string") + default: + return "", fmt.Errorf("want a string, got %T", n) + } +} + +func nodeBool(node ast.Node) (bool, error) { + n, ok := unwrapNode(node).(*ast.BoolNode) + if !ok { + return false, fmt.Errorf("want a boolean") + } + return n.Value, nil +} + +func nodeScalar(node ast.Node) (jsonScalar, error) { + switch n := unwrapNode(node).(type) { + case *ast.StringNode: + raw, err := json.Marshal(n.Value) + return jsonScalar{raw: raw}, err + case *ast.BoolNode: + if n.Value { + return jsonScalar{raw: []byte("true")}, nil + } + return jsonScalar{raw: []byte("false")}, nil + case *ast.IntegerNode: + return integerScalar(n.Value) + case *ast.FloatNode: + if n.Value == float64(int64(n.Value)) { + return jsonScalar{raw: []byte(strconv.FormatInt(int64(n.Value), 10))}, nil + } + raw, err := json.Marshal(n.Value) + return jsonScalar{raw: raw}, err + default: + return jsonScalar{}, fmt.Errorf("want a scalar, got %T", n) + } +} + +func integerScalar(v any) (jsonScalar, error) { + switch n := v.(type) { + case int64: + return jsonScalar{raw: []byte(strconv.FormatInt(n, 10))}, nil + case uint64: + return jsonScalar{raw: []byte(strconv.FormatUint(n, 10))}, nil + case int: + return jsonScalar{raw: []byte(strconv.Itoa(n))}, nil + default: + return jsonScalar{}, fmt.Errorf("want an integer, got %T", v) + } +} + +func nodeText(node ast.Node) string { + if node == nil { + return "" + } + if token := node.GetToken(); token != nil { + return token.Value + } + return "" +} + +func normalizeYAMLError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if strings.Contains(msg, "already defined") { + return fmt.Errorf("duplicate field: %w", err) + } + return err +} + +func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { + dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") + if dir == "." || strings.HasPrefix(dir, "..") { + return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) + } + cfgPath := path.Join(dir, "config_form.yaml") + if _, err := fs.Stat(fsys, cfgPath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) + } + return CompileForm(pluginID, ctl, fsys) } diff --git a/cabana/registry.go b/cabana/registry.go index d85074f..d98e242 100644 --- a/cabana/registry.go +++ b/cabana/registry.go @@ -49,10 +49,15 @@ func compileRegistry(items []controllerRef) (*Registry, error) { if err != nil { return nil, err } + form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, assets.AdminFS()) + if err != nil { + return nil, err + } byID[id] = &CompiledController{ PluginID: item.plugin.ID(), Controller: item.ctl, List: list, + Form: form, } } return &Registry{byID: byID}, nil diff --git a/cabana/schema_types.go b/cabana/schema_types.go new file mode 100644 index 0000000..5c99ff6 --- /dev/null +++ b/cabana/schema_types.go @@ -0,0 +1,71 @@ +package cabana + +import "encoding/json" + +// FormSchema is the locale-neutral form contract compiled once at boot. +// Display strings stay as source keys until a request localizes a copy. +type FormSchema struct { + Name string `json:"name,omitempty"` + ModelClass string `json:"modelClass,omitempty"` + Fields []FormField `json:"fields"` +} + +// FormField is one Winter field in source order. JSON keys keep Winter spelling. +type FormField struct { + Name string `json:"name"` + Type string `json:"type"` + Label string `json:"label,omitempty"` + Comment string `json:"comment,omitempty"` + Span string `json:"span,omitempty"` + Tab string `json:"tab,omitempty"` + Size string `json:"size,omitempty"` + Context *fieldContext `json:"context,omitempty"` + NameFrom string `json:"nameFrom,omitempty"` + EmptyOption string `json:"emptyOption,omitempty"` + Relation string `json:"relation,omitempty"` + Required bool `json:"required,omitempty"` + Default *jsonScalar `json:"default,omitempty"` + Attributes map[string]jsonScalar `json:"attributes,omitempty"` + Options []FormOption `json:"options,omitempty"` + + optionsMethod string +} + +// FormOption is one dropdown choice. Value keeps the YAML scalar's JSON type. +type FormOption struct { + Value jsonScalar `json:"value"` + Label string `json:"label"` +} + +// jsonScalar is a JSON scalar that still emits false, 0, and empty string. +type jsonScalar struct { + raw json.RawMessage +} + +func (s jsonScalar) MarshalJSON() ([]byte, error) { + if len(s.raw) == 0 { + return []byte("null"), nil + } + return s.raw, nil +} + +// fieldContext preserves a single Winter context string or a source-ordered list. +type fieldContext struct { + single bool + values []string +} + +func (c fieldContext) MarshalJSON() ([]byte, error) { + if c.single { + text := "" + if len(c.values) > 0 { + text = c.values[0] + } + return json.Marshal(text) + } + values := c.values + if values == nil { + values = []string{} + } + return json.Marshal(values) +}