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