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

@@ -0,0 +1,87 @@
package database
import (
"RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const (
articleSlugIndexName = "idx_articles_site_slug"
articleSlugIndexStatement = "CREATE UNIQUE INDEX " + articleSlugIndexName + " ON articles (site_id, slug)"
)
type slugOwner struct {
siteID uint32
slug string
}
type slugAssignment struct {
id uint32
slug string
}
func BackfillArticleSlugs(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var rows []entity.Article
if err := tx.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
return err
}
assignments := planSlugAssignments(rows)
for _, assignment := range assignments {
if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", assignment.id).UpdateColumn("slug", assignment.slug).Error; err != nil {
return err
}
}
return nil
})
}
func planSlugAssignments(rows []entity.Article) []slugAssignment {
assignments := []slugAssignment{}
claimed := map[slugOwner]bool{}
pending := []entity.Article{}
for _, row := range rows {
if row.DeletedAt.Valid {
if tombstone := article.Tombstone(row.ID); row.Slug != tombstone {
assignments = append(assignments, slugAssignment{row.ID, tombstone})
}
continue
}
owner := slugOwner{row.SiteID, row.Slug}
if row.Slug == "" || claimed[owner] {
pending = append(pending, row)
continue
}
claimed[owner] = true
}
for _, row := range pending {
base := row.Slug
attempt := 2
if base == "" {
base = snapshotSlug(row.Title)
attempt = 1
}
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt)
claimed[slugOwner{row.SiteID, slug}] = true
assignments = append(assignments, slugAssignment{row.ID, slug})
}
return assignments
}
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string {
for ; ; attempt++ {
candidate := snapshotSlugWithSuffix(base, attempt)
if !claimed[slugOwner{siteID, candidate}] {
return candidate
}
}
}
func ensureArticleSlugIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) {
return nil
}
return db.Exec(articleSlugIndexStatement).Error
}
@@ -0,0 +1,60 @@
package database
import (
"regexp"
"strconv"
"strings"
)
const (
snapshotMaxRunes = 100
snapshotFallbackSlug = "article"
)
var snapshotNonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
var snapshotFolder = strings.NewReplacer(
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
"æ", "ae",
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
"ď", "d", "đ", "d",
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
"ğ", "g",
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
"ł", "l", "ľ", "l",
"ñ", "n", "ń", "n", "ň", "n",
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
"œ", "oe",
"ř", "r",
"ś", "s", "š", "s", "ş", "s", "ș", "s",
"ß", "ss",
"ť", "t", "ț", "t", "ţ", "t",
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
"ý", "y", "ÿ", "y",
"ź", "z", "ż", "z", "ž", "z",
)
func snapshotSlug(text string) string {
folded := snapshotFolder.Replace(strings.ToLower(text))
generated := strings.Trim(snapshotNonSlugRun.ReplaceAllString(folded, "-"), "-")
generated = snapshotTruncate(generated, snapshotMaxRunes)
if generated == "" {
return snapshotFallbackSlug
}
return generated
}
func snapshotSlugWithSuffix(base string, attempt int) string {
if attempt <= 1 {
return base
}
suffix := "-" + strconv.Itoa(attempt)
return snapshotTruncate(base, snapshotMaxRunes-len(suffix)) + suffix
}
func snapshotTruncate(candidate string, limit int) string {
if len(candidate) <= limit {
return candidate
}
return strings.TrimRight(candidate[:limit], "-")
}
@@ -17,7 +17,16 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := BackfillUserRoles(db); err != nil {
return err
}
return SeedDefaultSite(db, publicURL)
if err := SeedDefaultSite(db, publicURL); err != nil {
return err
}
if err := BackfillSiteTitleTemplates(db); err != nil {
return err
}
if err := BackfillArticleSlugs(db); err != nil {
return err
}
return ensureArticleSlugIndex(db)
}
const (
@@ -36,6 +36,7 @@ func ensureDefaultSite(tx *gorm.DB, publicURL string) (entity.Site, error) {
BaseURL: baseURL,
DefaultRobots: domainSite.RobotsIndex,
IsDefault: true,
TitleTemplate: domainSite.DefaultTitleTemplate,
}
if err := tx.Create(&seeded).Error; err != nil {
return entity.Site{}, err
@@ -50,3 +51,7 @@ func firstDefaultSite(tx *gorm.DB) (entity.Site, error) {
}
return found, nil
}
func BackfillSiteTitleTemplates(db *gorm.DB) error {
return db.Model(&entity.Site{}).Where("title_template IS NULL OR title_template = ''").UpdateColumn("title_template", domainSite.DefaultTitleTemplate).Error
}
@@ -3,27 +3,76 @@ package mappers
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
const emptyDismissed = "[]"
func decodeDismissed(raw string) []string {
codes := []string{}
if raw == "" {
return codes
}
if err := json.Unmarshal([]byte(raw), &codes); err != nil || codes == nil {
return []string{}
}
return codes
}
func encodeDismissed(codes []string) string {
if len(codes) == 0 {
return emptyDismissed
}
encoded, err := json.Marshal(codes)
if err != nil {
return emptyDismissed
}
return string(encoded)
}
func ArticleToDomain(m models.Article) article.Article {
return article.Article{
ID: m.ID,
Title: m.Title,
Body: m.Body,
IsOnline: m.IsOnline,
SiteID: m.SiteID,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
ID: m.ID,
Title: m.Title,
Body: m.Body,
IsOnline: m.IsOnline,
SiteID: m.SiteID,
Slug: m.Slug,
Excerpt: m.Excerpt,
SEOTitle: m.SEOTitle,
SEODescription: m.SEODescription,
CoverImageID: m.CoverImageID,
CoverImageAlt: m.CoverImageAlt,
SocialTitle: m.SocialTitle,
SocialDescription: m.SocialDescription,
SocialImageID: m.SocialImageID,
CanonicalURL: m.CanonicalURL,
Noindex: m.Noindex,
SEODismissed: decodeDismissed(m.SEODismissed),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
func ArticleToModel(a article.Article) models.Article {
return models.Article{
Title: a.Title,
Body: a.Body,
IsOnline: a.IsOnline,
SiteID: a.SiteID,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
Title: a.Title,
Body: a.Body,
IsOnline: a.IsOnline,
SiteID: a.SiteID,
Slug: a.Slug,
Excerpt: a.Excerpt,
SEOTitle: a.SEOTitle,
SEODescription: a.SEODescription,
CoverImageID: a.CoverImageID,
CoverImageAlt: a.CoverImageAlt,
SocialTitle: a.SocialTitle,
SocialDescription: a.SocialDescription,
SocialImageID: a.SocialImageID,
CanonicalURL: a.CanonicalURL,
Noindex: a.Noindex,
SEODismissed: encodeDismissed(a.SEODismissed),
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}
@@ -6,6 +6,10 @@ import (
)
func SiteToDomain(m models.Site, articleCount int) site.Site {
titleTemplate := m.TitleTemplate
if titleTemplate == "" {
titleTemplate = site.DefaultTitleTemplate
}
return site.Site{
ID: m.ID,
Name: m.Name,
@@ -16,6 +20,9 @@ func SiteToDomain(m models.Site, articleCount int) site.Site {
Locale: m.Locale,
IsDefault: m.IsDefault,
ArticleCount: articleCount,
SiteName: m.SiteName,
TitleTemplate: titleTemplate,
DefaultDescription: m.DefaultDescription,
}
}
@@ -29,5 +36,8 @@ func SiteToModel(s site.Site) models.Site {
DefaultRobots: s.DefaultRobots,
Locale: s.Locale,
IsDefault: s.IsDefault,
SiteName: s.SiteName,
TitleTemplate: s.TitleTemplate,
DefaultDescription: s.DefaultDescription,
}
}
@@ -8,11 +8,23 @@ import (
type Article struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32 `gorm:"index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32 `gorm:"index"`
Slug string `gorm:"size:100;not null;default:''"`
Excerpt string `gorm:"size:300;not null;default:''"`
SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"`
SEODescription string `gorm:"column:seo_description;size:320;not null;default:''"`
CoverImageID *uint32 `gorm:"index"`
CoverImageAlt string `gorm:"size:200;not null;default:''"`
SocialTitle string `gorm:"size:120;not null;default:''"`
SocialDescription string `gorm:"size:320;not null;default:''"`
SocialImageID *uint32 `gorm:"index"`
CanonicalURL string `gorm:"column:canonical_url;size:2048;not null;default:''"`
Noindex bool `gorm:"not null;default:false"`
SEODismissed string `gorm:"column:seo_dismissed;type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -11,6 +11,9 @@ type Site struct {
DefaultRobots string `gorm:"size:16;not null"`
Locale *string `gorm:"size:35"`
IsDefault bool `gorm:"not null"`
SiteName string `gorm:"size:100;not null;default:''"`
TitleTemplate string `gorm:"size:200"`
DefaultDescription string `gorm:"size:320;not null;default:''"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -5,6 +5,7 @@ import (
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"strings"
"unicode"
@@ -260,35 +261,59 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := entity.Article{
Title: article.Title,
Body: article.Body,
IsOnline: article.IsOnline,
SiteID: article.SiteID,
}
created := mappers.ArticleToModel(article)
err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil {
return err
}
}
if err := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
return err
}
if err := tx.Create(&created).Error; err != nil {
return err
}
return replaceCredits(tx, created.ID, bylineIDs(article.Authors))
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
})
if err != nil {
return domain.Article{}, err
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func bylineIDs(bylines []authordomain.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
var total int64
err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
return total > 0, err
}
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
if slug == "" {
return nil
}
return ids
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
if err != nil {
return err
}
if taken {
return domain.ErrSlugTaken
}
return nil
}
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
if errors.Is(cause, domain.ErrSlugTaken) {
return cause
}
if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
return err
}
return cause
}
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
return articleSlugInUse(a.db, siteID, slug, exceptID)
}
func (a *ArticleRepository) GetAll() []domain.Article {
@@ -333,11 +358,16 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if update.Body != nil {
localArticle.Body = *update.Body
if update.SiteID == nil && update.Slug != nil {
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
return err
}
}
if update.SiteID != nil {
localArticle.SiteID = *update.SiteID
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
return err
}
}
if err := tx.Save(&localArticle).Error; err != nil {
return err
@@ -348,11 +378,44 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
return nil
})
if err != nil {
return domain.Article{}, err
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func applyUpdate(target *entity.Article, update domain.Update) {
setString(&target.Body, update.Body)
setString(&target.Slug, update.Slug)
setString(&target.Excerpt, update.Excerpt)
setString(&target.SEOTitle, update.SEOTitle)
setString(&target.SEODescription, update.SEODescription)
setString(&target.CoverImageAlt, update.CoverImageAlt)
setString(&target.SocialTitle, update.SocialTitle)
setString(&target.SocialDescription, update.SocialDescription)
setString(&target.CanonicalURL, update.CanonicalURL)
if update.SiteID != nil {
target.SiteID = *update.SiteID
}
if update.Noindex != nil {
target.Noindex = *update.Noindex
}
if update.CoverImageID.Set {
target.CoverImageID = update.CoverImageID.Value
}
if update.SocialImageID.Set {
target.SocialImageID = update.SocialImageID.Value
}
if update.SEODismissed != nil {
target.SEODismissed = mappers.ArticleToModel(domain.Article{SEODismissed: *update.SEODismissed}).SEODismissed
}
}
func setString(target *string, value *string) {
if value != nil {
*target = *value
}
}
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
@@ -371,6 +434,14 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
func (a *ArticleRepository) Delete(id uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
var found entity.Article
if err := tx.First(&found, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
tombstone := domain.Tombstone(id)
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil {
return err
}
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err
}
@@ -388,4 +459,73 @@ func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Arti
return toDomainArticles(entityArticles), nil
}
func (a *ArticleRepository) FindByImage(imageID uint32) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("cover_image_id = ? OR social_image_id = ?", imageID, imageID).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
return toDomainArticles(entityArticles), nil
}
func (a *ArticleRepository) ClearImage(imageID uint32) (domain.ImageReferences, error) {
references := domain.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := clearColumn(tx, "cover_image_id", imageID, &references.Cover); err != nil {
return err
}
return clearColumn(tx, "social_image_id", imageID, &references.Social)
})
if err != nil {
return domain.ImageReferences{}, err
}
return references, nil
}
func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32) error {
if err := tx.Model(&entity.Article{}).Where(column+" = ?", imageID).Order("id ASC").Pluck("id", cleared).Error; err != nil {
return err
}
if len(*cleared) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
}
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if err := assignColumn(tx, "cover_image_id", references.Cover, imageID); err != nil {
return err
}
return assignColumn(tx, "social_image_id", references.Social, imageID)
})
}
func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint32) error {
if len(articleIDs) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
}
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
changed := []uint32{}
if len(fromImageIDs) == 0 {
return changed, nil
}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
return err
}
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil {
return err
}
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error
})
if err != nil {
return nil, err
}
return changed, nil
}
var _ domain.Repository = &ArticleRepository{}
@@ -319,6 +319,14 @@ func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) {
return usage, nil
}
func (a *AuthorRepository) FindByAvatar(imageID uint32) ([]domain.Author, error) {
var found []entity.Author
if err := a.db.Where("avatar_id = ?", imageID).Order("id ASC").Find(&found).Error; err != nil {
return nil, err
}
return a.toDomain(a.db, found)
}
func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) {
ids := []uint32{}
err := a.db.Transaction(func(tx *gorm.DB) error {
@@ -106,11 +106,18 @@ func (s *SiteRepository) Create(site domain.Site) (domain.Site, error) {
return tx.Create(&model).Error
})
if err != nil {
return domain.Site{}, err
return domain.Site{}, s.explainKeyFailure(model.Key, err)
}
return mappers.SiteToDomain(model, 0), nil
}
func (s *SiteRepository) explainKeyFailure(key string, cause error) error {
if _, err := s.GetByKey(key); err == nil {
return domain.ErrSiteKeyTaken
}
return cause
}
func lockAllSites(tx *gorm.DB) error {
var locked []entity.Site
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&locked).Error
@@ -135,6 +142,9 @@ func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
"default_social_image_id": site.DefaultSocialImageID,
"default_robots": site.DefaultRobots,
"locale": site.Locale,
"site_name": site.SiteName,
"title_template": site.TitleTemplate,
"default_description": site.DefaultDescription,
}
if _, err := s.Get(site.ID); err != nil {
return domain.Site{}, err