186 lines
5.9 KiB
Go
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),
|
|
}
|
|
}
|