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 }