Files
RenewCMS/internal/infrastructure/useCases/ResolveArticleSEO.go
T

186 lines
5.9 KiB
Go

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),
}
}