121 lines
3.7 KiB
Go
121 lines
3.7 KiB
Go
// 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"`
|
|
}
|