141 lines
3.9 KiB
Go
141 lines
3.9 KiB
Go
package article
|
|
|
|
import (
|
|
"RenewCMS/internal/domain/author"
|
|
"RenewCMS/internal/domain/slug"
|
|
"errors"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidTitle = errors.New("article's title length should be between 3 and 200 caracters")
|
|
ErrArticleNotFound = errors.New("article not found")
|
|
ErrArticleOffline = errors.New("article is offline")
|
|
|
|
ErrInvalidSlug = errors.New("invalid slug")
|
|
ErrSlugTaken = errors.New("another article on this site already uses that slug")
|
|
ErrInvalidCanonicalURL = errors.New("invalid canonical url")
|
|
)
|
|
|
|
type SlugTakenError struct {
|
|
Suggestion string
|
|
}
|
|
|
|
func (e *SlugTakenError) Error() string {
|
|
return ErrSlugTaken.Error()
|
|
}
|
|
|
|
func (e *SlugTakenError) Is(target error) bool {
|
|
return target == ErrSlugTaken
|
|
}
|
|
|
|
const (
|
|
MinTitleRunes = 3
|
|
MaxTitleRunes = 200
|
|
|
|
MaxCanonicalURLRunes = 2048
|
|
|
|
FallbackSlug = "article"
|
|
DeletedSlug = "#deleted-"
|
|
|
|
DismissTitleLong = "title_long"
|
|
DismissTitleEmpty = "title_empty"
|
|
DismissDescShort = "desc_short"
|
|
DismissDescLong = "desc_long"
|
|
DismissCoverAlt = "cover_alt"
|
|
DismissCoverNone = "cover_none"
|
|
DismissSlugLong = "slug_long"
|
|
DismissNoAuthor = "no_au"
|
|
DismissNoHeading = "no_h"
|
|
DismissBodyAlt = "body_alt"
|
|
DismissShort = "short"
|
|
)
|
|
|
|
var dismissCodes = []string{
|
|
DismissTitleLong, DismissTitleEmpty, DismissDescShort, DismissDescLong, DismissCoverAlt, DismissCoverNone,
|
|
DismissSlugLong, DismissNoAuthor, DismissNoHeading, DismissBodyAlt, DismissShort,
|
|
}
|
|
|
|
func DismissCodes() []string {
|
|
return slices.Clone(dismissCodes)
|
|
}
|
|
|
|
func NormalizeDismissed(codes []string) []string {
|
|
normalized := make([]string, 0, len(codes))
|
|
for _, code := range codes {
|
|
if !slices.Contains(normalized, code) {
|
|
normalized = append(normalized, code)
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func IsValidSlug(candidate string) bool {
|
|
return slug.IsValid(candidate)
|
|
}
|
|
|
|
func Tombstone(id uint32) string {
|
|
return DeletedSlug + strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
|
|
func GenerateSlug(title string) string {
|
|
return slug.Generate(title, FallbackSlug)
|
|
}
|
|
|
|
func SlugWithSuffix(base string, attempt int) string {
|
|
return slug.WithSuffix(base, attempt)
|
|
}
|
|
|
|
func ValidateCanonicalURL(raw string) error {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
if utf8.RuneCountInString(raw) > MaxCanonicalURLRunes {
|
|
return ErrInvalidCanonicalURL
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
|
|
return ErrInvalidCanonicalURL
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateTitle(title string) error {
|
|
runeCount := utf8.RuneCountInString(title)
|
|
if runeCount < MinTitleRunes || runeCount > MaxTitleRunes {
|
|
return ErrInvalidTitle
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Article struct {
|
|
ID uint32 `json:"id"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
IsOnline bool `json:"is_online"`
|
|
SiteID uint32 `json:"site_id"`
|
|
Authors []author.Byline `json:"authors"`
|
|
Slug string `json:"slug"`
|
|
Excerpt string `json:"excerpt"`
|
|
SEOTitle string `json:"seo_title"`
|
|
SEODescription string `json:"seo_description"`
|
|
CoverImageID *uint32 `json:"cover_image_id"`
|
|
CoverImageAlt string `json:"cover_image_alt"`
|
|
SocialTitle string `json:"social_title"`
|
|
SocialDescription string `json:"social_description"`
|
|
SocialImageID *uint32 `json:"social_image_id"`
|
|
CanonicalURL string `json:"canonical_url"`
|
|
Noindex bool `json:"noindex"`
|
|
SEODismissed []string `json:"seo_dismissed"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func (a Article) UsesImage(imageID uint32) bool {
|
|
return a.CoverImageID != nil && *a.CoverImageID == imageID || a.SocialImageID != nil && *a.SocialImageID == imageID
|
|
}
|