// SPDX-License-Identifier: MPL-2.0 // Package cms supplies bounded classification and editorial relationship values. // It does not define content types, layouts, authorization, or commerce policy. // Applications own those decisions and publish exact immutable revisions. package cms import ( "errors" "regexp" "strings" "unicode" "unicode/utf8" ) var ( ErrInvalid = errors.New("cms: invalid value") ErrConflict = errors.New("cms: revision or slug conflict") ErrNotFound = errors.New("cms: not found") identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`) slug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) kind = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`) ) const MaxTerms = 24 const MaxLinks = 16 // Reference names an application-owned resource, never a mutable URL or title. type Reference struct { Kind string `json:"kind"` ID string `json:"id"` } func ValidID(value string) bool { return identifier.MatchString(value) } func ValidSlug(value string) bool { return len(value) <= 80 && slug.MatchString(value) } func ValidText(value string, max int) bool { return utf8.ValidString(value) && len(value) <= max && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, unicode.IsControl) } func (r Reference) Validate() error { if !kind.MatchString(r.Kind) || !ValidID(r.ID) { return ErrInvalid } return nil } // Associations are an immutable revision's term memberships and explicit links. // Reverse discovery reads the same links; callers must not store a second edge. // Terms and Links are independently optional. The zero value is valid for a // valid source; unclassified content never needs a placeholder taxonomy or link. type Associations struct { Terms []string `json:"terms,omitempty"` Links []Reference `json:"links,omitempty"` } func (a Associations) Validate(source Reference) error { if source.Validate() != nil || len(a.Terms) > MaxTerms || len(a.Links) > MaxLinks { return ErrInvalid } terms := map[string]bool{} for _, id := range a.Terms { if !ValidID(id) || terms[id] { return ErrInvalid } terms[id] = true } links := map[Reference]bool{} for _, ref := range a.Links { if ref.Validate() != nil || ref == source || links[ref] { return ErrInvalid } links[ref] = true } return nil } // Taxonomy is an editor-named vocabulary, not a database-defined content type. // Slug is fixed after creation; Name/Description and availability may change. type Taxonomy struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description,omitempty"` Revision int64 `json:"revision"` Active bool `json:"active"` } func (v Taxonomy) Validate() error { if !ValidID(v.ID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 { return ErrInvalid } return nil } // Term identity survives renaming and retirement. Retirement hides discovery; // it does not rewrite old associations or imply removal of related resources. type Term struct { ID string `json:"id"` TaxonomyID string `json:"taxonomy_id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description,omitempty"` Revision int64 `json:"revision"` Active bool `json:"active"` } func (v Term) Validate() error { if !ValidID(v.ID) || !ValidID(v.TaxonomyID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 { return ErrInvalid } return nil } type ResourceVersion struct { Reference Revision int64 `json:"revision"` } type Page struct { Items []ResourceVersion `json:"items"` Next *Reference `json:"next,omitempty"` }