Files
web/cms/cms.go
T
2026-09-10 18:00:25 -04:00

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"`
}