feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+6094 -350

No files matched your search

+110 -8
View File
@@ -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)
}