feat: public API, SEO and existing surfaces

This commit is contained in:
Florian Sylvain committed 2026-11-03 03:58:10 +01:00
1 parent 45fbbc33b1
commit c390fcfe8d
103 files changed
+4476 -231

No files matched your search

@@ -3,6 +3,7 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"errors"
"strings"
@@ -26,10 +27,53 @@ 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) *ResolveArticleSEOUseCase {
return &ResolveArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
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: confirmedDefault(rows),
locale: locale,
alternates: alternatesOf(owner, siblings),
localeAlternates: localeAlternates,
}, nil
}
func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (article.ResolvedSEO, error) {
@@ -48,7 +92,11 @@ func (u *ResolveArticleSEOUseCase) Resolve(id uint32, caller ContentCaller) (art
if err != nil {
return article.ResolvedSEO{}, err
}
return buildResolvedSEO(art, owner, picked), nil
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) {
@@ -96,11 +144,11 @@ 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
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 {
@@ -144,10 +192,12 @@ func buildJSONLD(headline string, description string, picked article.SEOImage, a
return ld
}
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage) article.ResolvedSEO {
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)
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body))
siteTextAllowed := art.Lang == "" || art.Lang == spoken.defaultTag
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)
@@ -156,10 +206,6 @@ func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOIm
if picked.URL != "" {
card = twitterCardLarge
}
locale := ""
if owner.Locale != nil {
locale = *owner.Locale
}
var published *string
if art.IsOnline {
formatted := formatSEOTime(art.CreatedAt)
@@ -172,14 +218,16 @@ func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOIm
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,
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},
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,
}
}