feat: SEO backend output (2/4)
This commit is contained in:
1 parent
0eb46b0d74
commit
eb9876fde3
32 files changed
+3859
-65
No files matched your search
@@ -14,6 +14,7 @@ const (
|
||||
SortByStatus SortField = "status"
|
||||
SortByCreatedAt SortField = "created_at"
|
||||
SortByUpdatedAt SortField = "updated_at"
|
||||
SortByID SortField = "id"
|
||||
|
||||
SortAsc SortDirection = "asc"
|
||||
SortDesc SortDirection = "desc"
|
||||
@@ -36,6 +37,7 @@ type Filters struct {
|
||||
Q string
|
||||
Status string
|
||||
Sort Sort
|
||||
Unpaged bool
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package article
|
||||
|
||||
const (
|
||||
SEOSourceSEOTitle = "seo_title"
|
||||
SEOSourceTitle = "title"
|
||||
SEOSourceSEODescription = "seo_description"
|
||||
SEOSourceExcerpt = "excerpt"
|
||||
SEOSourceBody = "body"
|
||||
SEOSourceSite = "site"
|
||||
SEOSourceNone = "none"
|
||||
SEOSourceSocial = "social"
|
||||
SEOSourceCover = "cover"
|
||||
SEOSourceCanonicalURL = "canonical_url"
|
||||
)
|
||||
|
||||
type SEOOpenGraph struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
ImageAlt string `json:"image_alt"`
|
||||
URL string `json:"url"`
|
||||
SiteName string `json:"site_name"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type SEOTwitter struct {
|
||||
Card string `json:"card"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
ImageAlt string `json:"image_alt"`
|
||||
}
|
||||
|
||||
type SEOImage struct {
|
||||
URL string `json:"url"`
|
||||
Alt string `json:"alt"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type SEOAuthor struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
SameAs []string `json:"same_as"`
|
||||
}
|
||||
|
||||
type SEOFallbacks struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Canonical string `json:"canonical"`
|
||||
}
|
||||
|
||||
type SEOJSONLDPerson struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
SameAs []string `json:"sameAs"`
|
||||
}
|
||||
|
||||
type SEOJSONLDPage struct {
|
||||
Type string `json:"@type"`
|
||||
ID string `json:"@id"`
|
||||
}
|
||||
|
||||
type SEOJSONLD struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
Headline string `json:"headline"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Image []string `json:"image,omitempty"`
|
||||
Author []SEOJSONLDPerson `json:"author,omitempty"`
|
||||
DatePublished string `json:"datePublished,omitempty"`
|
||||
DateModified string `json:"dateModified"`
|
||||
MainEntityOfPage SEOJSONLDPage `json:"mainEntityOfPage"`
|
||||
}
|
||||
|
||||
type ResolvedSEO struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Robots string `json:"robots"`
|
||||
OG SEOOpenGraph `json:"og"`
|
||||
Twitter SEOTwitter `json:"twitter"`
|
||||
Image SEOImage `json:"image"`
|
||||
Authors []SEOAuthor `json:"authors"`
|
||||
PublishedAt *string `json:"published_at"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Fallbacks SEOFallbacks `json:"fallbacks"`
|
||||
JSONLD SEOJSONLD `json:"json_ld"`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package article
|
||||
|
||||
const (
|
||||
SEOStatusReady = "ready"
|
||||
SEOStatusSuggestions = "suggestions"
|
||||
SEOStatusHidden = "hidden"
|
||||
|
||||
SEOTitleMaxRunes = 60
|
||||
SEODescriptionMinRunes = 70
|
||||
SEODescriptionMaxRunes = 160
|
||||
SEOSlugMaxRunes = 75
|
||||
SEOHeadingMinTextRunes = 1000
|
||||
SEOShortBodyRunes = 300
|
||||
|
||||
SEOFieldTitle = "seo_title"
|
||||
SEOFieldDescription = "seo_description"
|
||||
SEOFieldCoverAlt = "cover_image_alt"
|
||||
SEOFieldCoverID = "cover_image_id"
|
||||
SEOFieldSlug = "slug"
|
||||
SEOFieldAuthors = "author_ids"
|
||||
SEOFieldBody = "body"
|
||||
)
|
||||
|
||||
type SEOSuggestion struct {
|
||||
Code string `json:"code"`
|
||||
Field string `json:"field"`
|
||||
Params map[string]int `json:"params"`
|
||||
}
|
||||
|
||||
type SEOAssessment struct {
|
||||
Status string `json:"status"`
|
||||
Suggestions []SEOSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
type SEOSummary struct {
|
||||
Status string
|
||||
Suggestions int
|
||||
}
|
||||
|
||||
func (a SEOAssessment) Summary() SEOSummary {
|
||||
return SEOSummary{Status: a.Status, Suggestions: len(a.Suggestions)}
|
||||
}
|
||||
|
||||
type SEOSlugCheck struct {
|
||||
Available bool `json:"available"`
|
||||
Suggestion *string `json:"suggestion"`
|
||||
}
|
||||
|
||||
type SEOCheck struct {
|
||||
Status string `json:"status"`
|
||||
Suggestions []SEOSuggestion `json:"suggestions"`
|
||||
Slug SEOSlugCheck `json:"slug"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -59,6 +60,7 @@ var sortColumns = map[domain.SortField]string{
|
||||
domain.SortByStatus: "is_online",
|
||||
domain.SortByCreatedAt: "created_at",
|
||||
domain.SortByUpdatedAt: "updated_at",
|
||||
domain.SortByID: "id",
|
||||
}
|
||||
|
||||
var sortDirections = map[domain.SortDirection]string{
|
||||
@@ -72,6 +74,9 @@ func orderClause(sort domain.Sort) string {
|
||||
if !ok || !directionOk {
|
||||
column, direction = sortColumns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
|
||||
}
|
||||
if sort.Field == domain.SortByID {
|
||||
return column + " " + direction
|
||||
}
|
||||
return column + " " + direction + ", id DESC"
|
||||
}
|
||||
|
||||
@@ -87,7 +92,11 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
var entityArticles []entity.Article
|
||||
err := a.scoped(filters).Order(orderClause(filters.Sort)).Limit(filters.Size).Offset((filters.Page - 1) * filters.Size).Find(&entityArticles).Error
|
||||
ordered := a.scoped(filters).Order(orderClause(filters.Sort))
|
||||
if !filters.Unpaged {
|
||||
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
|
||||
}
|
||||
err := ordered.Find(&entityArticles).Error
|
||||
if err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
@@ -110,16 +119,23 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
|
||||
matchingIds = append(matchingIds, candidate.ID)
|
||||
}
|
||||
}
|
||||
start := min((filters.Page-1)*filters.Size, len(matchingIds))
|
||||
end := min(start+filters.Size, len(matchingIds))
|
||||
pageIds := matchingIds[start:end]
|
||||
pageIds := matchingIds
|
||||
if !filters.Unpaged {
|
||||
start := min((filters.Page-1)*filters.Size, len(matchingIds))
|
||||
end := min(start+filters.Size, len(matchingIds))
|
||||
pageIds = matchingIds[start:end]
|
||||
}
|
||||
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
|
||||
if len(pageIds) == 0 {
|
||||
return page, nil
|
||||
}
|
||||
var entityArticles []entity.Article
|
||||
if err := a.db.Model(&entity.Article{}).Where("id IN ?", pageIds).Find(&entityArticles).Error; err != nil {
|
||||
return domain.PageResult{}, err
|
||||
for _, chunk := range chunkIDs(pageIds) {
|
||||
var loaded []entity.Article
|
||||
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
entityArticles = append(entityArticles, loaded...)
|
||||
}
|
||||
byId := make(map[uint32]entity.Article, len(entityArticles))
|
||||
for _, entityArticle := range entityArticles {
|
||||
@@ -186,15 +202,35 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
|
||||
return loaded[0], nil
|
||||
}
|
||||
|
||||
const idChunkSize = 500
|
||||
|
||||
func chunkIDs(ids []uint32) [][]uint32 {
|
||||
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
|
||||
for start := 0; start < len(ids); start += idChunkSize {
|
||||
chunks = append(chunks, ids[start:min(start+idChunkSize, len(ids))])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
|
||||
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
|
||||
if len(articleIDs) == 0 {
|
||||
return bylines, nil
|
||||
}
|
||||
var credits []entity.ArticleAuthor
|
||||
if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil {
|
||||
return nil, err
|
||||
for _, chunk := range chunkIDs(articleIDs) {
|
||||
var loaded []entity.ArticleAuthor
|
||||
if err := db.Where("article_id IN ?", chunk).Order("position ASC, id ASC").Find(&loaded).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credits = append(credits, loaded...)
|
||||
}
|
||||
sort.SliceStable(credits, func(i, j int) bool {
|
||||
if credits[i].Position != credits[j].Position {
|
||||
return credits[i].Position < credits[j].Position
|
||||
}
|
||||
return credits[i].ID < credits[j].ID
|
||||
})
|
||||
if len(credits) == 0 {
|
||||
return bylines, nil
|
||||
}
|
||||
@@ -203,8 +239,12 @@ func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.By
|
||||
authorIDs = append(authorIDs, credit.AuthorID)
|
||||
}
|
||||
var found []entity.Author
|
||||
if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
for _, chunk := range chunkIDs(authorIDs) {
|
||||
var loaded []entity.Author
|
||||
if err := db.Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, loaded...)
|
||||
}
|
||||
byID := make(map[uint32]entity.Author, len(found))
|
||||
for _, model := range found {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
|
||||
type CheckArticleSEOUseCase struct {
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
images image.Repository
|
||||
}
|
||||
|
||||
type CheckArticleSEOCommand struct {
|
||||
Body *string
|
||||
Slug *string
|
||||
Excerpt *string
|
||||
SEOTitle *string
|
||||
SEODescription *string
|
||||
CoverImageID article.Nullable[uint32]
|
||||
CoverImageAlt *string
|
||||
SocialTitle *string
|
||||
SocialDescription *string
|
||||
SocialImageID article.Nullable[uint32]
|
||||
CanonicalURL *string
|
||||
Noindex *bool
|
||||
SEODismissed *[]string
|
||||
}
|
||||
|
||||
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *CheckArticleSEOUseCase {
|
||||
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
|
||||
}
|
||||
|
||||
func (u *CheckArticleSEOUseCase) Check(id uint32, command CheckArticleSEOCommand) (article.SEOCheck, error) {
|
||||
saved, err := u.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
if err := u.validate(command); err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
owner, err := u.sites.Get(saved.SiteID)
|
||||
if err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
candidate := command.applyTo(saved)
|
||||
slugCheck, err := u.checkSlug(candidate)
|
||||
if err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
assessment := assessSEO(candidate, owner)
|
||||
return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil
|
||||
}
|
||||
|
||||
func (u *CheckArticleSEOUseCase) validate(command CheckArticleSEOCommand) error {
|
||||
if command.Slug != nil && *command.Slug != "" && !article.IsValidSlug(*command.Slug) {
|
||||
return article.ErrInvalidSlug
|
||||
}
|
||||
if command.CanonicalURL != nil {
|
||||
if err := article.ValidateCanonicalURL(*command.CanonicalURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if command.CoverImageID.Set {
|
||||
if err := ensureImageExists(u.images, command.CoverImageID.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if command.SocialImageID.Set {
|
||||
return ensureImageExists(u.images, command.SocialImageID.Value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func overrideText(current string, override *string) string {
|
||||
if override == nil {
|
||||
return current
|
||||
}
|
||||
return *override
|
||||
}
|
||||
|
||||
func overrideImage(current *uint32, override article.Nullable[uint32]) *uint32 {
|
||||
if override.Set {
|
||||
return override.Value
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (c CheckArticleSEOCommand) applyTo(saved article.Article) article.Article {
|
||||
saved.Body = overrideText(saved.Body, c.Body)
|
||||
saved.Excerpt = overrideText(saved.Excerpt, c.Excerpt)
|
||||
saved.SEOTitle = overrideText(saved.SEOTitle, c.SEOTitle)
|
||||
saved.SEODescription = overrideText(saved.SEODescription, c.SEODescription)
|
||||
saved.CoverImageID = overrideImage(saved.CoverImageID, c.CoverImageID)
|
||||
saved.CoverImageAlt = overrideText(saved.CoverImageAlt, c.CoverImageAlt)
|
||||
saved.SocialTitle = overrideText(saved.SocialTitle, c.SocialTitle)
|
||||
saved.SocialDescription = overrideText(saved.SocialDescription, c.SocialDescription)
|
||||
saved.SocialImageID = overrideImage(saved.SocialImageID, c.SocialImageID)
|
||||
saved.CanonicalURL = overrideText(saved.CanonicalURL, c.CanonicalURL)
|
||||
if c.Noindex != nil {
|
||||
saved.Noindex = *c.Noindex
|
||||
}
|
||||
if c.SEODismissed != nil {
|
||||
saved.SEODismissed = article.NormalizeDismissed(*c.SEODismissed)
|
||||
}
|
||||
if c.Slug != nil {
|
||||
saved.Slug = *c.Slug
|
||||
if saved.Slug == "" {
|
||||
saved.Slug = article.GenerateSlug(saved.Title)
|
||||
}
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.SEOSlugCheck, error) {
|
||||
available := article.SEOSlugCheck{Available: true}
|
||||
if candidate.Slug == "" {
|
||||
return available, nil
|
||||
}
|
||||
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Slug, candidate.ID)
|
||||
if err != nil || !taken {
|
||||
return available, err
|
||||
}
|
||||
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Slug, candidate.ID, firstSuggestedAttempt)
|
||||
if err != nil {
|
||||
return article.SEOSlugCheck{}, err
|
||||
}
|
||||
return article.SEOSlugCheck{Suggestion: &suggestion}, nil
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type ListArticlesCommand struct {
|
||||
Status string
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
}
|
||||
|
||||
type ArticlePage struct {
|
||||
@@ -33,6 +34,7 @@ type ArticlePage struct {
|
||||
Page int
|
||||
Size int
|
||||
Counts article.Counts
|
||||
SEO map[uint32]article.SEOSummary
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
|
||||
@@ -40,7 +42,7 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts}
|
||||
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
|
||||
if !command.Verified && command.Status == article.StatusDraft {
|
||||
return page, nil
|
||||
}
|
||||
@@ -55,19 +57,69 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
}
|
||||
filters.SiteID = &siteID
|
||||
}
|
||||
filters.Unpaged = command.SEO != ""
|
||||
found, err := u.articleRepository.FindPage(filters)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
|
||||
summaries, err := u.summarize(found.Items)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
items, total := found.Items, found.Total
|
||||
if command.SEO != "" {
|
||||
items, total = pageBySEOStatus(found.Items, summaries, command)
|
||||
}
|
||||
rendered, err := renderArticles(u.bodyRenderer, items, command.Format)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
page.Items = rendered
|
||||
page.Total = found.Total
|
||||
page.Total = total
|
||||
page.SEO = summaries
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
|
||||
matching := make([]article.Article, 0, len(items))
|
||||
for _, item := range items {
|
||||
if summaries[item.ID].Status == command.SEO {
|
||||
matching = append(matching, item)
|
||||
}
|
||||
}
|
||||
size := min(max(command.Size, 0), len(matching))
|
||||
if size == 0 {
|
||||
return []article.Article{}, len(matching)
|
||||
}
|
||||
pages := (len(matching) + size - 1) / size
|
||||
pageIndex := max(command.Page, 1) - 1
|
||||
if pageIndex >= pages {
|
||||
return []article.Article{}, len(matching)
|
||||
}
|
||||
start := pageIndex * size
|
||||
end := min(start+size, len(matching))
|
||||
return matching[start:end], len(matching)
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
|
||||
summaries := make(map[uint32]article.SEOSummary, len(items))
|
||||
if len(items) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
known, err := u.sites.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners := make(map[uint32]site.Site, len(known))
|
||||
for _, owner := range known {
|
||||
owners[owner.ID] = owner
|
||||
}
|
||||
for _, item := range items {
|
||||
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
|
||||
found, err := u.sites.GetByKey(key)
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListSitemapUseCase struct {
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository) *ListSitemapUseCase {
|
||||
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites}
|
||||
}
|
||||
|
||||
type SitemapEntry struct {
|
||||
URL string
|
||||
ModifiedAt time.Time
|
||||
Noindex bool
|
||||
}
|
||||
|
||||
type SitemapPage struct {
|
||||
Entries []SitemapEntry
|
||||
Total int
|
||||
}
|
||||
|
||||
func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPage, error) {
|
||||
owner, err := u.sites.GetByKey(siteKey)
|
||||
if err != nil {
|
||||
return SitemapPage{}, err
|
||||
}
|
||||
online := true
|
||||
found, err := u.articleRepository.FindPage(article.Filters{
|
||||
IsOnline: &online,
|
||||
SiteID: &owner.ID,
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: article.Sort{Field: article.SortByID, Direction: article.SortAsc},
|
||||
})
|
||||
if err != nil {
|
||||
return SitemapPage{}, err
|
||||
}
|
||||
entries := make([]SitemapEntry, 0, len(found.Items))
|
||||
for _, item := range found.Items {
|
||||
entries = append(entries, SitemapEntry{URL: articleURL(owner, item.Slug), ModifiedAt: item.UpdatedAt, Noindex: item.Noindex})
|
||||
}
|
||||
return SitemapPage{Entries: entries, Total: found.Total}, nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
openGraphArticleType = "article"
|
||||
twitterCardLarge = "summary_large_image"
|
||||
twitterCardSmall = "summary"
|
||||
robotsIndexed = "index, follow"
|
||||
robotsNotIndexed = "noindex, nofollow"
|
||||
jsonLDContext = "https://schema.org"
|
||||
jsonLDArticle = "Article"
|
||||
jsonLDPerson = "Person"
|
||||
jsonLDWebPage = "WebPage"
|
||||
sitePlaceholder = "%site%"
|
||||
)
|
||||
|
||||
type ResolveArticleSEOUseCase struct {
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
images image.Repository
|
||||
}
|
||||
|
||||
func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *ResolveArticleSEOUseCase {
|
||||
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
|
||||
}
|
||||
|
||||
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, verified bool) (article.ResolvedSEO, error) {
|
||||
art, err := u.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.ResolvedSEO{}, err
|
||||
}
|
||||
if !art.IsOnline && !verified {
|
||||
return article.ResolvedSEO{}, article.ErrArticleNotFound
|
||||
}
|
||||
owner, err := u.sites.Get(art.SiteID)
|
||||
if err != nil {
|
||||
return article.ResolvedSEO{}, err
|
||||
}
|
||||
picked, err := u.resolveImage(art, owner)
|
||||
if err != nil {
|
||||
return article.ResolvedSEO{}, err
|
||||
}
|
||||
return buildResolvedSEO(art, owner, picked), nil
|
||||
}
|
||||
|
||||
func (u *ResolveArticleSEOUseCase) resolveImage(art article.Article, owner site.Site) (article.SEOImage, error) {
|
||||
candidates := []struct {
|
||||
id *uint32
|
||||
alt string
|
||||
source string
|
||||
}{
|
||||
{art.SocialImageID, "", article.SEOSourceSocial},
|
||||
{art.CoverImageID, art.CoverImageAlt, article.SEOSourceCover},
|
||||
{owner.DefaultSocialImageID, "", article.SEOSourceSite},
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.id == nil {
|
||||
continue
|
||||
}
|
||||
found, err := u.images.Get(*candidate.id)
|
||||
if errors.Is(err, image.ErrImageNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return article.SEOImage{}, err
|
||||
}
|
||||
return article.SEOImage{URL: owner.BaseURL + found.Path, Alt: candidate.alt, Source: candidate.source}, nil
|
||||
}
|
||||
return article.SEOImage{Source: article.SEOSourceNone}, nil
|
||||
}
|
||||
|
||||
func applyTitleTemplate(template string, title string, siteName string) string {
|
||||
if template == "" {
|
||||
template = site.DefaultTitleTemplate
|
||||
}
|
||||
replacer := strings.NewReplacer(site.TitlePlaceholder, title, sitePlaceholder, siteName)
|
||||
return strings.TrimSpace(replacer.Replace(template))
|
||||
}
|
||||
|
||||
func resolveRobots(art article.Article, owner site.Site) string {
|
||||
if art.Noindex || owner.DefaultRobots == site.RobotsNoIndex {
|
||||
return robotsNotIndexed
|
||||
}
|
||||
return robotsIndexed
|
||||
}
|
||||
|
||||
func resolveCanonical(art article.Article, owner site.Site) (string, string) {
|
||||
if art.CanonicalURL != "" {
|
||||
return art.CanonicalURL, article.SEOSourceCanonicalURL
|
||||
}
|
||||
return articleURL(owner, art.Slug), article.SEOSourceSite
|
||||
}
|
||||
|
||||
func articleURL(owner site.Site, slug string) string {
|
||||
return owner.BaseURL + "/" + slug
|
||||
}
|
||||
|
||||
func resolveAuthors(art article.Article) []article.SEOAuthor {
|
||||
authors := make([]article.SEOAuthor, 0, len(art.Authors))
|
||||
for _, byline := range art.Authors {
|
||||
profile := ""
|
||||
if byline.Website != nil {
|
||||
profile = *byline.Website
|
||||
}
|
||||
sameAs := make([]string, 0, len(byline.SocialLinks))
|
||||
for _, link := range byline.SocialLinks {
|
||||
sameAs = append(sameAs, link.URL)
|
||||
}
|
||||
authors = append(authors, article.SEOAuthor{Name: byline.Name, URL: profile, SameAs: sameAs})
|
||||
}
|
||||
return authors
|
||||
}
|
||||
|
||||
func formatSEOTime(moment time.Time) string {
|
||||
return moment.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func buildJSONLD(headline string, description string, picked article.SEOImage, authors []article.SEOAuthor, art article.Article, canonical string) article.SEOJSONLD {
|
||||
ld := article.SEOJSONLD{
|
||||
Context: jsonLDContext,
|
||||
Type: jsonLDArticle,
|
||||
Headline: headline,
|
||||
Description: description,
|
||||
DateModified: formatSEOTime(art.UpdatedAt),
|
||||
MainEntityOfPage: article.SEOJSONLDPage{Type: jsonLDWebPage, ID: canonical},
|
||||
}
|
||||
if picked.URL != "" {
|
||||
ld.Image = []string{picked.URL}
|
||||
}
|
||||
for _, person := range authors {
|
||||
ld.Author = append(ld.Author, article.SEOJSONLDPerson{Type: jsonLDPerson, Name: person.Name, URL: person.URL, SameAs: person.SameAs})
|
||||
}
|
||||
if art.IsOnline {
|
||||
ld.DatePublished = formatSEOTime(art.CreatedAt)
|
||||
}
|
||||
return ld
|
||||
}
|
||||
|
||||
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage) article.ResolvedSEO {
|
||||
meta, titleSource := metaTitle(art)
|
||||
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
|
||||
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body))
|
||||
canonical, canonicalSource := resolveCanonical(art, owner)
|
||||
authors := resolveAuthors(art)
|
||||
shareTitle := firstNonEmpty(art.SocialTitle, title)
|
||||
shareDescription := firstNonEmpty(art.SocialDescription, description)
|
||||
card := twitterCardSmall
|
||||
if picked.URL != "" {
|
||||
card = twitterCardLarge
|
||||
}
|
||||
locale := ""
|
||||
if owner.Locale != nil {
|
||||
locale = *owner.Locale
|
||||
}
|
||||
var published *string
|
||||
if art.IsOnline {
|
||||
formatted := formatSEOTime(art.CreatedAt)
|
||||
published = &formatted
|
||||
}
|
||||
return article.ResolvedSEO{
|
||||
Title: title,
|
||||
Description: description,
|
||||
CanonicalURL: canonical,
|
||||
Robots: resolveRobots(art, owner),
|
||||
OG: article.SEOOpenGraph{
|
||||
Type: openGraphArticleType, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt,
|
||||
URL: canonical, SiteName: owner.SiteName, Locale: locale,
|
||||
},
|
||||
Twitter: article.SEOTwitter{Card: card, Title: shareTitle, Description: shareDescription, Image: picked.URL, ImageAlt: picked.Alt},
|
||||
Image: picked,
|
||||
Authors: authors,
|
||||
PublishedAt: published,
|
||||
ModifiedAt: formatSEOTime(art.UpdatedAt),
|
||||
Fallbacks: article.SEOFallbacks{Title: titleSource, Description: descriptionSource, Image: picked.Source, Canonical: canonicalSource},
|
||||
JSONLD: buildJSONLD(meta, description, picked, authors, art, canonical),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type bodyFacts struct {
|
||||
spacedText string
|
||||
textRunes int
|
||||
hasSubheading bool
|
||||
imagesWithoutAlt int
|
||||
}
|
||||
|
||||
var blockElements = map[string]bool{
|
||||
"p": true, "div": true, "br": true, "li": true, "ul": true, "ol": true, "blockquote": true, "pre": true,
|
||||
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "tr": true, "section": true, "article": true,
|
||||
"figure": true, "figcaption": true, "table": true, "hr": true,
|
||||
}
|
||||
|
||||
var subheadings = map[string]bool{"h2": true, "h3": true, "h4": true, "h5": true, "h6": true}
|
||||
|
||||
var skippedElements = map[string]bool{"script": true, "style": true}
|
||||
|
||||
const sentenceTerminators = ".!?"
|
||||
|
||||
func analyzeBody(body string) bodyFacts {
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(body))
|
||||
var plain, spaced strings.Builder
|
||||
facts := bodyFacts{}
|
||||
skipping := false
|
||||
for {
|
||||
kind := tokenizer.Next()
|
||||
if kind == html.ErrorToken {
|
||||
break
|
||||
}
|
||||
switch kind {
|
||||
case html.TextToken:
|
||||
if !skipping {
|
||||
text := string(tokenizer.Text())
|
||||
plain.WriteString(text)
|
||||
spaced.WriteString(text)
|
||||
}
|
||||
case html.StartTagToken, html.SelfClosingTagToken, html.EndTagToken:
|
||||
token := tokenizer.Token()
|
||||
skipping = nextSkipping(skipping, kind, token)
|
||||
if blockElements[token.Data] {
|
||||
spaced.WriteString(" ")
|
||||
}
|
||||
if kind == html.EndTagToken {
|
||||
continue
|
||||
}
|
||||
if subheadings[token.Data] {
|
||||
facts.hasSubheading = true
|
||||
}
|
||||
if token.Data == "img" && !hasAlt(token) {
|
||||
facts.imagesWithoutAlt++
|
||||
}
|
||||
}
|
||||
}
|
||||
facts.textRunes = utf8.RuneCountInString(strings.TrimSpace(plain.String()))
|
||||
facts.spacedText = strings.Join(strings.Fields(spaced.String()), " ")
|
||||
return facts
|
||||
}
|
||||
|
||||
func nextSkipping(skipping bool, kind html.TokenType, token html.Token) bool {
|
||||
if !skippedElements[token.Data] {
|
||||
return skipping
|
||||
}
|
||||
switch kind {
|
||||
case html.StartTagToken:
|
||||
return true
|
||||
case html.EndTagToken:
|
||||
return false
|
||||
}
|
||||
return skipping
|
||||
}
|
||||
|
||||
func hasAlt(token html.Token) bool {
|
||||
for _, attribute := range token.Attr {
|
||||
if attribute.Key == "alt" {
|
||||
return strings.TrimSpace(attribute.Val) != ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstSentence(text string) string {
|
||||
runes := []rune(text)
|
||||
for index, character := range runes {
|
||||
if !strings.ContainsRune(sentenceTerminators, character) {
|
||||
continue
|
||||
}
|
||||
if index+1 == len(runes) || runes[index+1] == ' ' {
|
||||
return string(runes[:index+1])
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func metaTitle(art article.Article) (string, string) {
|
||||
if art.SEOTitle != "" {
|
||||
return art.SEOTitle, article.SEOSourceSEOTitle
|
||||
}
|
||||
return art.Title, article.SEOSourceTitle
|
||||
}
|
||||
|
||||
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (string, string) {
|
||||
if art.SEODescription != "" {
|
||||
return art.SEODescription, article.SEOSourceSEODescription
|
||||
}
|
||||
if art.Excerpt != "" {
|
||||
return art.Excerpt, article.SEOSourceExcerpt
|
||||
}
|
||||
if sentence := firstSentence(facts.spacedText); sentence != "" {
|
||||
return sentence, article.SEOSourceBody
|
||||
}
|
||||
if owner.DefaultDescription != "" {
|
||||
return owner.DefaultDescription, article.SEOSourceSite
|
||||
}
|
||||
return "", article.SEOSourceNone
|
||||
}
|
||||
|
||||
func suggestion(code string, field string, params map[string]int) article.SEOSuggestion {
|
||||
return article.SEOSuggestion{Code: code, Field: field, Params: params}
|
||||
}
|
||||
|
||||
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []article.SEOSuggestion {
|
||||
suggestions := []article.SEOSuggestion{}
|
||||
title, _ := metaTitle(art)
|
||||
titleRunes := utf8.RuneCountInString(title)
|
||||
if titleRunes > article.SEOTitleMaxRunes {
|
||||
suggestions = append(suggestions, suggestion(article.DismissTitleLong, article.SEOFieldTitle, map[string]int{"length": titleRunes, "max": article.SEOTitleMaxRunes}))
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
|
||||
}
|
||||
description, _ := resolveDescription(art, owner, facts)
|
||||
descriptionRunes := utf8.RuneCountInString(description)
|
||||
if descriptionRunes < article.SEODescriptionMinRunes {
|
||||
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
|
||||
}
|
||||
if descriptionRunes > article.SEODescriptionMaxRunes {
|
||||
suggestions = append(suggestions, suggestion(article.DismissDescLong, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "max": article.SEODescriptionMaxRunes}))
|
||||
}
|
||||
if art.CoverImageID != nil && strings.TrimSpace(art.CoverImageAlt) == "" {
|
||||
suggestions = append(suggestions, suggestion(article.DismissCoverAlt, article.SEOFieldCoverAlt, map[string]int{}))
|
||||
}
|
||||
if art.CoverImageID == nil {
|
||||
suggestions = append(suggestions, suggestion(article.DismissCoverNone, article.SEOFieldCoverID, map[string]int{}))
|
||||
}
|
||||
slugRunes := utf8.RuneCountInString(art.Slug)
|
||||
if slugRunes > article.SEOSlugMaxRunes {
|
||||
suggestions = append(suggestions, suggestion(article.DismissSlugLong, article.SEOFieldSlug, map[string]int{"length": slugRunes, "max": article.SEOSlugMaxRunes}))
|
||||
}
|
||||
if len(art.Authors) == 0 {
|
||||
suggestions = append(suggestions, suggestion(article.DismissNoAuthor, article.SEOFieldAuthors, map[string]int{}))
|
||||
}
|
||||
if facts.textRunes >= article.SEOHeadingMinTextRunes && !facts.hasSubheading {
|
||||
suggestions = append(suggestions, suggestion(article.DismissNoHeading, article.SEOFieldBody, map[string]int{}))
|
||||
}
|
||||
if facts.imagesWithoutAlt > 0 {
|
||||
suggestions = append(suggestions, suggestion(article.DismissBodyAlt, article.SEOFieldBody, map[string]int{"count": facts.imagesWithoutAlt}))
|
||||
}
|
||||
if facts.textRunes < article.SEOShortBodyRunes {
|
||||
suggestions = append(suggestions, suggestion(article.DismissShort, article.SEOFieldBody, map[string]int{"length": facts.textRunes, "min": article.SEOShortBodyRunes}))
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func assessSEO(art article.Article, owner site.Site) article.SEOAssessment {
|
||||
facts := analyzeBody(art.Body)
|
||||
kept := []article.SEOSuggestion{}
|
||||
for _, found := range rawSuggestions(art, owner, facts) {
|
||||
if !slices.Contains(art.SEODismissed, found.Code) {
|
||||
kept = append(kept, found)
|
||||
}
|
||||
}
|
||||
status := article.SEOStatusReady
|
||||
if len(kept) > 0 {
|
||||
status = article.SEOStatusSuggestions
|
||||
}
|
||||
if art.Noindex {
|
||||
status = article.SEOStatusHidden
|
||||
}
|
||||
return article.SEOAssessment{Status: status, Suggestions: kept}
|
||||
}
|
||||
Reference in new issue
Block a user