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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user