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

+13
View File
@@ -0,0 +1,13 @@
package article
import "errors"
var ErrLanguageNotAvailable = errors.New("no published version in that language")
type Alternate struct {
ID uint32 `json:"id"`
Lang string `json:"lang"`
StoryID uint32 `json:"story_id"`
Slug string `json:"slug"`
URL *string `json:"url"`
}
@@ -38,6 +38,7 @@ type Filters struct {
Q string
Status string
Lang *string
Langs *[]string
AuthorID *uint32
Sort Sort
Unpaged bool
@@ -101,6 +102,7 @@ type Repository interface {
ListByStories(storyIDs []uint32) ([]Article, error)
Get(id uint32) (Article, error)
GetByName(name string) (Article, error)
GetByLangSlug(siteID uint32, lang string, slug string) (Article, error)
GetAll() ([]Article, error)
ListForAccount(creatorID uint32, authorID *uint32) ([]Article, error)
Create(post Article) (Article, error)
+7
View File
@@ -11,6 +11,7 @@ const (
SEOSourceSocial = "social"
SEOSourceCover = "cover"
SEOSourceCanonicalURL = "canonical_url"
SEOSourcePattern = "pattern"
)
type SEOOpenGraph struct {
@@ -22,6 +23,8 @@ type SEOOpenGraph struct {
URL string `json:"url"`
SiteName string `json:"site_name"`
Locale string `json:"locale"`
LocaleAlternates []string `json:"locale_alternates"`
}
type SEOTwitter struct {
@@ -49,6 +52,8 @@ type SEOFallbacks struct {
Description string `json:"description"`
Image string `json:"image"`
Canonical string `json:"canonical"`
SiteTextSkipped bool `json:"site_text_skipped"`
}
type SEOJSONLDPerson struct {
@@ -88,4 +93,6 @@ type ResolvedSEO struct {
ModifiedAt string `json:"modified_at"`
Fallbacks SEOFallbacks `json:"fallbacks"`
JSONLD SEOJSONLD `json:"json_ld"`
Lang string `json:"lang"`
Alternates []Alternate `json:"alternates"`
}
@@ -9,6 +9,8 @@ import (
var socialLocalePattern = regexp.MustCompile(`^([A-Za-z]{2,3})_([A-Za-z]{2})$`)
var regionPattern = regexp.MustCompile(`^[A-Za-z]{2}$`)
func NormalizeSocialLocale(tag string, social *string) (*string, error) {
if social == nil {
return nil, nil
@@ -25,25 +27,38 @@ func NormalizeSocialLocale(tag string, social *string) (*string, error) {
return &normalized, nil
}
func SocialLocaleState(row Row) string {
func SocialLocaleState(row Row, siteLocale *string) string {
if row.SocialLocale != nil {
return SocialLocaleSet
}
if _, region, found := strings.Cut(row.Tag, "-"); found && region != "" {
if EffectiveSocialLocale(row, siteLocale) != nil {
return SocialLocaleDerived
}
return SocialLocaleNotSet
}
func EffectiveSocialLocale(row Row) *string {
switch SocialLocaleState(row) {
case SocialLocaleSet:
func EffectiveSocialLocale(row Row, siteLocale *string) *string {
if row.SocialLocale != nil {
return row.SocialLocale
case SocialLocaleDerived:
}
if _, region, found := strings.Cut(row.Tag, "-"); found && region != "" {
derived := strings.Replace(row.Tag, "-", "_", 1)
return &derived
}
return nil
return borrowedSocialLocale(row.Tag, siteLocale)
}
func borrowedSocialLocale(tag string, siteLocale *string) *string {
if siteLocale == nil {
return nil
}
tagLanguage, _, _ := strings.Cut(tag, "-")
parts := strings.Split(*siteLocale, "-")
if len(parts) < 2 || !strings.EqualFold(parts[0], tagLanguage) || !regionPattern.MatchString(parts[1]) {
return nil
}
borrowed := strings.ToLower(parts[0]) + "_" + strings.ToUpper(parts[1])
return &borrowed
}
func normalizeEntries(entries []Entry) ([]Entry, error) {
@@ -49,7 +49,7 @@ func Available() []Option {
return options
}
func BuildView(rows []Row, counts map[string]VersionCount) View {
func BuildView(rows []Row, counts map[string]VersionCount, siteLocale *string) View {
view := View{Revision: Revision(rows), NeedsSetup: NeedsSetup(rows), Languages: []LanguageView{}, Available: Available()}
if view.NeedsSetup {
return view
@@ -68,7 +68,7 @@ func BuildView(rows []Row, counts map[string]VersionCount) View {
VersionCount: counts[row.Tag].Total,
PublishedCount: counts[row.Tag].Published,
SocialLocale: row.SocialLocale,
SocialLocaleState: SocialLocaleState(row),
SocialLocaleState: SocialLocaleState(row, siteLocale),
})
}
return view
+22
View File
@@ -0,0 +1,22 @@
package site
import "strings"
func (s Site) PublicURL(lang string, slug string) *string {
if s.PublicURLPattern == nil || lang == "" {
return nil
}
filled := strings.NewReplacer(LangPlaceholder, lang, SlugPlaceholder, slug).Replace(*s.PublicURLPattern)
return &filled
}
func (s Site) ArticleURL(lang string, slug string) (string, bool) {
if localized := s.PublicURL(lang, slug); localized != nil {
return *localized, true
}
return s.LegacyURL(slug), false
}
func (s Site) LegacyURL(slug string) string {
return s.BaseURL + "/" + slug
}
+3 -1
View File
@@ -41,6 +41,7 @@ type listArticlesInput struct {
Sort string `json:"sort,omitempty" jsonschema:"field:direction, e.g. title:asc or updated_at:desc"`
Site string `json:"site,omitempty" jsonschema:"site key"`
SEO string `json:"seo,omitempty" jsonschema:"ready, suggestions or hidden"`
Lang string `json:"lang,omitempty" jsonschema:"language tag of the versions to list, e.g. en or pt-BR"`
Format string `json:"format,omitempty" jsonschema:"body format: markdown, html or json"`
}
@@ -50,7 +51,7 @@ type getArticleInput struct {
}
type createArticleInput struct {
Title string `json:"title" jsonschema:"3 to 200 characters, cannot be changed later"`
Title string `json:"title" jsonschema:"3 to 200 characters"`
Body string `json:"body" jsonschema:"HTML body"`
}
@@ -180,6 +181,7 @@ func (s *server) listArticles(ctx context.Context, _ *mcp.CallToolRequest, in li
textQuery(query, "sort", in.Sort)
textQuery(query, "site", in.Site)
textQuery(query, "seo", in.SEO)
textQuery(query, "lang", in.Lang)
textQuery(query, "format", in.Format)
return s.call(ctx, http.MethodGet, "/v1/article/", query, nil)
}
@@ -64,12 +64,22 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
if filters.Lang != nil {
query = query.Where("lang = ?", *filters.Lang)
}
if filters.Langs != nil {
query = restrictToLangs(query, *filters.Langs)
}
if filters.AuthorID != nil {
query = query.Where("id IN (SELECT article_id FROM article_authors WHERE author_id = ?)", *filters.AuthorID)
}
return query
}
func restrictToLangs(query *gorm.DB, langs []string) *gorm.DB {
if len(langs) == 0 {
return query.Where("1 = 0")
}
return query.Where("lang IN ?", langs)
}
var sortColumns = map[domain.SortField]string{
domain.SortByTitle: "LOWER(title)",
domain.SortByStatus: "is_online",
@@ -348,6 +358,15 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
}
func (a *ArticleRepository) GetByLangSlug(siteID uint32, lang string, slug string) (domain.Article, error) {
var found entity.Article
err := a.db.Model(&entity.Article{}).Where("site_id = ? AND lang = ? AND slug = ?", siteID, lang, slug).First(&found).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(found))
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := mappers.ArticleToModel(article.Opened())
err := a.db.Transaction(func(tx *gorm.DB) error {
@@ -29,6 +29,8 @@ type MediaUsage struct {
Title string
Published *bool
Fields []string
StoryID *uint32
Lang *string
ContentTypeKey string
ContentTypeName string
@@ -138,7 +140,8 @@ func usageFor(byID map[uint32]*MediaUsage, found article.Article) *MediaUsage {
return entry
}
published := found.IsOnline
entry := &MediaUsage{Type: UsageTypeArticle, ID: found.ID, Title: found.Title, Published: &published, Fields: []string{}}
storyID, lang := found.StoryID, found.Lang
entry := &MediaUsage{Type: UsageTypeArticle, ID: found.ID, Title: found.Title, Published: &published, Fields: []string{}, StoryID: &storyID, Lang: &lang}
byID[found.ID] = entry
return entry
}
@@ -173,3 +176,23 @@ func (u *GetMediaUsageUseCase) siteUsages(img image.Image) ([]MediaUsage, error)
func articleMentionsImage(a article.Article, img image.Image) bool {
return image.ContainsStoredName(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
}
type articleGroup struct {
story uint32
article uint32
}
func groupOf(art article.Article) articleGroup {
if art.Lang == "" || art.StoryID == 0 {
return articleGroup{article: art.ID}
}
return articleGroup{story: art.StoryID}
}
func distinctArticleCount(articles []article.Article) int {
groups := map[articleGroup]bool{}
for _, art := range articles {
groups[groupOf(art)] = true
}
return len(groups)
}
@@ -19,17 +19,17 @@ func (g *GetSiteLanguagesUseCase) Get(key string) (localization.View, error) {
if err != nil {
return localization.View{}, err
}
return readSiteLanguages(g.localization, found.ID)
return readSiteLanguages(g.localization, found)
}
func readSiteLanguages(repository localization.Repository, siteID uint32) (localization.View, error) {
rows, err := repository.Rows(siteID)
func readSiteLanguages(repository localization.Repository, owner site.Site) (localization.View, error) {
rows, err := repository.Rows(owner.ID)
if err != nil {
return localization.View{}, err
}
counts, err := repository.VersionCounts(siteID)
counts, err := repository.VersionCounts(owner.ID)
if err != nil {
return localization.View{}, err
}
return localization.BuildView(rows, counts), nil
return localization.BuildView(rows, counts, owner.Locale), nil
}
@@ -8,6 +8,7 @@ import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"maps"
"slices"
"strings"
)
@@ -160,11 +161,11 @@ func (l *ListMediaUseCase) articleUsageCount(img image.Image) (int, error) {
if err != nil {
return 0, err
}
distinct := map[uint32]bool{}
distinct := map[uint32]article.Article{}
for _, found := range slices.Concat(inBody, referencing) {
distinct[found.ID] = true
distinct[found.ID] = found
}
return len(distinct), nil
return distinctArticleCount(slices.Collect(maps.Values(distinct))), nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
@@ -177,13 +178,13 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
return nil, err
}
return func(img image.Image) int {
used := siteUsage[img.ID]
mentioning := []article.Article{}
for _, a := range articles {
if articleMentionsImage(a, img) {
used++
mentioning = append(mentioning, a)
}
}
return used
return siteUsage[img.ID] + distinctArticleCount(mentioning)
}, nil
}
@@ -2,6 +2,8 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"time"
)
@@ -9,16 +11,18 @@ import (
type ListSitemapUseCase struct {
articleRepository article.Repository
sites site.Repository
localization localization.Repository
}
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository) *ListSitemapUseCase {
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites}
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository, localizationRepository localization.Repository) *ListSitemapUseCase {
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites, localization: localizationRepository}
}
type SitemapEntry struct {
URL string
ModifiedAt time.Time
Noindex bool
Lang string
}
type SitemapPage struct {
@@ -31,10 +35,21 @@ func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPa
if err != nil {
return SitemapPage{}, err
}
rows, err := u.localization.Rows(owner.ID)
if err != nil {
return SitemapPage{}, err
}
langs := []string{language.Unassigned}
for _, row := range rows {
if row.Enabled {
langs = append(langs, row.Tag)
}
}
online := true
found, err := u.articleRepository.FindPage(article.Filters{
IsOnline: &online,
SiteID: &owner.ID,
Langs: &langs,
Page: page,
Size: size,
Sort: article.Sort{Field: article.SortByID, Direction: article.SortAsc},
@@ -44,7 +59,12 @@ func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPa
}
entries := make([]SitemapEntry, 0, len(found.Items))
for _, item := range found.Items {
entries = append(entries, SitemapEntry{URL: articleURL(owner, item.Slug), ModifiedAt: item.UpdatedAt, Noindex: item.Noindex})
entries = append(entries, SitemapEntry{URL: sitemapURL(owner, item), ModifiedAt: item.UpdatedAt, Noindex: item.Noindex, Lang: item.Lang})
}
return SitemapPage{Entries: entries, Total: found.Total}, nil
}
func sitemapURL(owner site.Site, item article.Article) string {
url, _ := owner.ArticleURL(item.Lang, item.Slug)
return url
}
@@ -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,
}
}
@@ -0,0 +1,152 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"errors"
)
type ResolvePublicArticleUseCase struct {
articles article.Repository
sites site.Repository
localization localization.Repository
bodyRenderer article.BodyRenderer
}
func NewResolvePublicArticleUseCase(articles article.Repository, sites site.Repository, localizationRepository localization.Repository, bodyRenderer article.BodyRenderer) *ResolvePublicArticleUseCase {
return &ResolvePublicArticleUseCase{articles: articles, sites: sites, localization: localizationRepository, bodyRenderer: bodyRenderer}
}
type PublicArticle struct {
Article article.Article
Viewer ContentCaller
Context ArticleContext
Alternates []article.Alternate
RequestedLang string
Fallback bool
}
type PublicStoryQuery struct {
SiteKey string
StoryID uint32
Lang *string
FallbackDefault bool
Format article.BodyFormat
}
type PublicLookupQuery struct {
SiteKey string
Lang string
Slug string
Format article.BodyFormat
}
func (u *ResolvePublicArticleUseCase) ByLangSlug(query PublicLookupQuery) (PublicArticle, error) {
tag, err := language.Normalize(query.Lang)
if errors.Is(err, language.ErrInvalidLanguage) {
return PublicArticle{}, err
}
unsupported := errors.Is(err, language.ErrUnsupportedLanguage)
owner, err := u.sites.GetByKey(query.SiteKey)
if err != nil {
return PublicArticle{}, err
}
if unsupported {
return PublicArticle{}, article.ErrArticleNotFound
}
found, err := u.articles.GetByLangSlug(owner.ID, tag, query.Slug)
if err != nil {
return PublicArticle{}, err
}
if !found.IsOnline {
return PublicArticle{}, article.ErrArticleNotFound
}
rows, err := u.localization.Rows(owner.ID)
if err != nil {
return PublicArticle{}, err
}
versions, err := storyVersions(u.articles, found)
if err != nil {
return PublicArticle{}, err
}
return u.present(owner, rows, versions, found, query.Format)
}
func (u *ResolvePublicArticleUseCase) ByStory(query PublicStoryQuery) (PublicArticle, error) {
requested := ""
unsupported := false
if query.Lang != nil {
tag, err := language.Normalize(*query.Lang)
if errors.Is(err, language.ErrInvalidLanguage) {
return PublicArticle{}, err
}
requested = tag
unsupported = errors.Is(err, language.ErrUnsupportedLanguage)
}
owner, err := u.sites.GetByKey(query.SiteKey)
if err != nil {
return PublicArticle{}, err
}
rows, err := u.localization.Rows(owner.ID)
if err != nil {
return PublicArticle{}, err
}
if unsupported {
return PublicArticle{}, article.ErrLanguageNotAvailable
}
defaultTag := confirmedDefault(rows)
if query.Lang == nil {
requested = defaultTag
}
versions, err := u.articles.ListByStory(query.StoryID)
if err != nil {
return PublicArticle{}, err
}
chosen, fallback, ok := chooseStoryVersion(owner, versions, requested, defaultTag, query.FallbackDefault)
if !ok {
return PublicArticle{}, article.ErrLanguageNotAvailable
}
presented, err := u.present(owner, rows, versions, chosen, query.Format)
if err != nil {
return PublicArticle{}, err
}
presented.RequestedLang = requested
presented.Fallback = fallback
return presented, nil
}
func chooseStoryVersion(owner site.Site, versions []article.Article, requested string, defaultTag string, fallbackDefault bool) (article.Article, bool, bool) {
if requested == "" {
return article.Article{}, false, false
}
if found, ok := publishedVersion(owner, versions, requested); ok {
return found, false, true
}
if fallbackDefault && defaultTag != "" && defaultTag != requested {
if found, ok := publishedVersion(owner, versions, defaultTag); ok {
return found, true, true
}
}
return article.Article{}, false, false
}
func publishedVersion(owner site.Site, versions []article.Article, tag string) (article.Article, bool) {
for _, version := range versions {
if version.SiteID == owner.ID && version.Lang == tag && version.IsOnline {
return version, true
}
}
return article.Article{}, false
}
func (u *ResolvePublicArticleUseCase) present(owner site.Site, rows []localization.Row, versions []article.Article, found article.Article, format article.BodyFormat) (PublicArticle, error) {
rendered, err := renderArticle(u.bodyRenderer, found, format)
if err != nil {
return PublicArticle{}, err
}
viewer := ContentCaller{}
described := ArticleContext{Capabilities: viewer.capabilitiesFor(found), Translations: []article.Translation{}}
return PublicArticle{Article: rendered, Viewer: viewer, Context: described, Alternates: alternatesOf(owner, eligibleSiblings(rows, versions, found))}, nil
}
@@ -70,7 +70,7 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
return err
}
}
view, err = readSiteLanguages(tx.Localization, found.ID)
view, err = readSiteLanguages(tx.Localization, found)
return err
})
if errors.Is(err, transaction.ErrLockContention) {
@@ -0,0 +1,63 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"slices"
)
func confirmedDefault(rows []localization.Row) string {
if localization.NeedsSetup(rows) {
return ""
}
return localization.DefaultTag(rows)
}
func eligibleSiblings(rows []localization.Row, versions []article.Article, served article.Article) []article.Article {
defaultTag := confirmedDefault(rows)
siblings := []article.Article{}
for _, version := range versions {
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline || version.Lang == "" {
continue
}
if localization.EnabledTag(rows, version.Lang) {
siblings = append(siblings, version)
}
}
slices.SortFunc(siblings, func(left article.Article, right article.Article) int {
return localization.CompareTags(defaultTag, left.Lang, right.Lang)
})
return siblings
}
func storyVersions(repository article.Repository, art article.Article) ([]article.Article, error) {
if art.StoryID == 0 {
return []article.Article{}, nil
}
return repository.ListByStory(art.StoryID)
}
func alternatesOf(owner site.Site, siblings []article.Article) []article.Alternate {
alternates := make([]article.Alternate, 0, len(siblings))
for _, sibling := range siblings {
alternates = append(alternates, article.Alternate{ID: sibling.ID, Lang: sibling.Lang, StoryID: sibling.StoryID, Slug: sibling.Slug, URL: owner.PublicURL(sibling.Lang, sibling.Slug)})
}
return alternates
}
func rowOf(rows []localization.Row, tag string) localization.Row {
for _, row := range rows {
if row.Tag == tag {
return row
}
}
return localization.Row{Tag: tag}
}
func socialLocaleOf(rows []localization.Row, tag string, siteLocale *string) string {
if resolved := localization.EffectiveSocialLocale(rowOf(rows, tag), siteLocale); resolved != nil {
return *resolved
}
return ""
}
+5 -3
View File
@@ -15,7 +15,9 @@ func metaTitle(art article.Article) (string, string) {
return art.Title, article.SEOSourceTitle
}
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (string, string) {
const withSiteText = true
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) (string, string) {
if art.SEODescription != "" {
return art.SEODescription, article.SEOSourceSEODescription
}
@@ -25,7 +27,7 @@ func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (
if sentence := firstSentence(facts.spacedText); sentence != "" {
return sentence, article.SEOSourceBody
}
if owner.DefaultDescription != "" {
if siteTextAllowed && owner.DefaultDescription != "" {
return owner.DefaultDescription, article.SEOSourceSite
}
return "", article.SEOSourceNone
@@ -45,7 +47,7 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
if strings.TrimSpace(title) == "" {
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
}
description, _ := resolveDescription(art, owner, facts)
description, _ := resolveDescription(art, owner, facts, withSiteText)
descriptionRunes := utf8.RuneCountInString(description)
if descriptionRunes < article.SEODescriptionMinRunes {
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))