package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/localization" "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" ) type ResolveArticleSEOUseCase struct { articleRepository article.Repository sites site.Repository images image.Repository localization localization.Repository } func NewResolveArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository, localizationRepository localization.Repository) *ResolveArticleSEOUseCase { return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images, localization: localizationRepository} } type seoLanguage struct { defaultTag string locale string alternates []article.Alternate localeAlternates []string } func (u *ResolveArticleSEOUseCase) languageOf(art article.Article, owner site.Site) (seoLanguage, error) { if art.Lang == "" { locale := "" if owner.Locale != nil { locale = *owner.Locale } return seoLanguage{alternates: []article.Alternate{}, localeAlternates: []string{}, locale: locale}, nil } rows, err := u.localization.Rows(art.SiteID) if err != nil { return seoLanguage{}, err } versions, err := storyVersions(u.articleRepository, art) if err != nil { return seoLanguage{}, err } siblings := eligibleSiblings(rows, versions, art) localeAlternates := []string{} for _, sibling := range siblings { if resolved := socialLocaleOf(rows, sibling.Lang, owner.Locale); resolved != "" { localeAlternates = append(localeAlternates, resolved) } } locale := socialLocaleOf(rows, art.Lang, owner.Locale) if locale == "" { localeAlternates = []string{} } return seoLanguage{ defaultTag: localization.ConfirmedDefaultTag(rows), locale: locale, alternates: alternatesOf(owner, siblings), localeAlternates: localeAlternates, }, nil } func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (article.ResolvedSEO, error) { art, err := u.articleRepository.Get(id) if err != nil { return article.ResolvedSEO{}, err } if !caller.seesArticle(art) { 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 } spoken, err := u.languageOf(art, owner) if err != nil { return article.ResolvedSEO{}, err } return buildResolvedSEO(art, owner, picked, spoken), 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, site.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 } url, patterned := owner.ArticleURL(art.Lang, art.Slug) if patterned { return url, article.SEOSourcePattern } return url, article.SEOSourceSite } 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, spoken seoLanguage) article.ResolvedSEO { meta, titleSource := metaTitle(art) title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName) siteTextAllowed := localization.SiteTextAllowed(spoken.defaultTag, art.Lang) description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body), siteTextAllowed) siteTextSkipped := descriptionSource == article.SEOSourceNone && owner.DefaultDescription != "" 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 } 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: spoken.locale, LocaleAlternates: spoken.localeAlternates, }, 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, SiteTextSkipped: siteTextSkipped}, JSONLD: buildJSONLD(meta, description, picked, authors, art, canonical), Lang: art.Lang, Alternates: spoken.alternates, } }