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