feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -2,7 +2,11 @@ package article
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/slug"
|
||||
"errors"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -11,13 +15,95 @@ 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 {
|
||||
@@ -27,12 +113,28 @@ func ValidateTitle(title string) error {
|
||||
}
|
||||
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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
|
||||
}
|
||||
@@ -38,10 +38,36 @@ type Filters struct {
|
||||
Sort Sort
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
Slug *string
|
||||
Excerpt *string
|
||||
SEOTitle *string
|
||||
SEODescription *string
|
||||
CoverImageID Nullable[uint32]
|
||||
CoverImageAlt *string
|
||||
SocialTitle *string
|
||||
SocialDescription *string
|
||||
SocialImageID Nullable[uint32]
|
||||
CanonicalURL *string
|
||||
Noindex *bool
|
||||
SEODismissed *[]string
|
||||
}
|
||||
|
||||
type ImageReferences struct {
|
||||
Cover []uint32
|
||||
Social []uint32
|
||||
}
|
||||
|
||||
func (r ImageReferences) Empty() bool {
|
||||
return len(r.Cover) == 0 && len(r.Social) == 0
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
@@ -68,4 +94,9 @@ type Repository interface {
|
||||
UpdateIsOnline(id uint32, isOnline bool) (Article, error)
|
||||
Delete(id uint32) error
|
||||
FindByBodyContaining(filename string) ([]Article, error)
|
||||
FindByImage(imageID uint32) ([]Article, error)
|
||||
SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error)
|
||||
ClearImage(imageID uint32) (ImageReferences, error)
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
}
|
||||
Reference in new issue
Block a user