feat: SEO backend output (2/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 21:48:55 +02:00
1 parent 0eb46b0d74
commit eb9876fde3
32 files changed
+3859 -65

No files matched your search

@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"sort"
"strings"
"unicode"
@@ -59,6 +60,7 @@ var sortColumns = map[domain.SortField]string{
domain.SortByStatus: "is_online",
domain.SortByCreatedAt: "created_at",
domain.SortByUpdatedAt: "updated_at",
domain.SortByID: "id",
}
var sortDirections = map[domain.SortDirection]string{
@@ -72,6 +74,9 @@ func orderClause(sort domain.Sort) string {
if !ok || !directionOk {
column, direction = sortColumns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
}
if sort.Field == domain.SortByID {
return column + " " + direction
}
return column + " " + direction + ", id DESC"
}
@@ -87,7 +92,11 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
return domain.PageResult{}, err
}
var entityArticles []entity.Article
err := a.scoped(filters).Order(orderClause(filters.Sort)).Limit(filters.Size).Offset((filters.Page - 1) * filters.Size).Find(&entityArticles).Error
ordered := a.scoped(filters).Order(orderClause(filters.Sort))
if !filters.Unpaged {
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
}
err := ordered.Find(&entityArticles).Error
if err != nil {
return domain.PageResult{}, err
}
@@ -110,16 +119,23 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
matchingIds = append(matchingIds, candidate.ID)
}
}
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds := matchingIds[start:end]
pageIds := matchingIds
if !filters.Unpaged {
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds = matchingIds[start:end]
}
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
if len(pageIds) == 0 {
return page, nil
}
var entityArticles []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", pageIds).Find(&entityArticles).Error; err != nil {
return domain.PageResult{}, err
for _, chunk := range chunkIDs(pageIds) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return domain.PageResult{}, err
}
entityArticles = append(entityArticles, loaded...)
}
byId := make(map[uint32]entity.Article, len(entityArticles))
for _, entityArticle := range entityArticles {
@@ -186,15 +202,35 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil
}
const idChunkSize = 500
func chunkIDs(ids []uint32) [][]uint32 {
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
for start := 0; start < len(ids); start += idChunkSize {
chunks = append(chunks, ids[start:min(start+idChunkSize, len(ids))])
}
return chunks
}
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
if len(articleIDs) == 0 {
return bylines, nil
}
var credits []entity.ArticleAuthor
if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil {
return nil, err
for _, chunk := range chunkIDs(articleIDs) {
var loaded []entity.ArticleAuthor
if err := db.Where("article_id IN ?", chunk).Order("position ASC, id ASC").Find(&loaded).Error; err != nil {
return nil, err
}
credits = append(credits, loaded...)
}
sort.SliceStable(credits, func(i, j int) bool {
if credits[i].Position != credits[j].Position {
return credits[i].Position < credits[j].Position
}
return credits[i].ID < credits[j].ID
})
if len(credits) == 0 {
return bylines, nil
}
@@ -203,8 +239,12 @@ func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.By
authorIDs = append(authorIDs, credit.AuthorID)
}
var found []entity.Author
if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil {
return nil, err
for _, chunk := range chunkIDs(authorIDs) {
var loaded []entity.Author
if err := db.Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
}
byID := make(map[uint32]entity.Author, len(found))
for _, model := range found {
@@ -0,0 +1,130 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
)
type CheckArticleSEOUseCase struct {
articleRepository article.Repository
sites site.Repository
images image.Repository
}
type CheckArticleSEOCommand struct {
Body *string
Slug *string
Excerpt *string
SEOTitle *string
SEODescription *string
CoverImageID article.Nullable[uint32]
CoverImageAlt *string
SocialTitle *string
SocialDescription *string
SocialImageID article.Nullable[uint32]
CanonicalURL *string
Noindex *bool
SEODismissed *[]string
}
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *CheckArticleSEOUseCase {
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
}
func (u *CheckArticleSEOUseCase) Check(id uint32, command CheckArticleSEOCommand) (article.SEOCheck, error) {
saved, err := u.articleRepository.Get(id)
if err != nil {
return article.SEOCheck{}, err
}
if err := u.validate(command); err != nil {
return article.SEOCheck{}, err
}
owner, err := u.sites.Get(saved.SiteID)
if err != nil {
return article.SEOCheck{}, err
}
candidate := command.applyTo(saved)
slugCheck, err := u.checkSlug(candidate)
if err != nil {
return article.SEOCheck{}, err
}
assessment := assessSEO(candidate, owner)
return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil
}
func (u *CheckArticleSEOUseCase) validate(command CheckArticleSEOCommand) error {
if command.Slug != nil && *command.Slug != "" && !article.IsValidSlug(*command.Slug) {
return article.ErrInvalidSlug
}
if command.CanonicalURL != nil {
if err := article.ValidateCanonicalURL(*command.CanonicalURL); err != nil {
return err
}
}
if command.CoverImageID.Set {
if err := ensureImageExists(u.images, command.CoverImageID.Value); err != nil {
return err
}
}
if command.SocialImageID.Set {
return ensureImageExists(u.images, command.SocialImageID.Value)
}
return nil
}
func overrideText(current string, override *string) string {
if override == nil {
return current
}
return *override
}
func overrideImage(current *uint32, override article.Nullable[uint32]) *uint32 {
if override.Set {
return override.Value
}
return current
}
func (c CheckArticleSEOCommand) applyTo(saved article.Article) article.Article {
saved.Body = overrideText(saved.Body, c.Body)
saved.Excerpt = overrideText(saved.Excerpt, c.Excerpt)
saved.SEOTitle = overrideText(saved.SEOTitle, c.SEOTitle)
saved.SEODescription = overrideText(saved.SEODescription, c.SEODescription)
saved.CoverImageID = overrideImage(saved.CoverImageID, c.CoverImageID)
saved.CoverImageAlt = overrideText(saved.CoverImageAlt, c.CoverImageAlt)
saved.SocialTitle = overrideText(saved.SocialTitle, c.SocialTitle)
saved.SocialDescription = overrideText(saved.SocialDescription, c.SocialDescription)
saved.SocialImageID = overrideImage(saved.SocialImageID, c.SocialImageID)
saved.CanonicalURL = overrideText(saved.CanonicalURL, c.CanonicalURL)
if c.Noindex != nil {
saved.Noindex = *c.Noindex
}
if c.SEODismissed != nil {
saved.SEODismissed = article.NormalizeDismissed(*c.SEODismissed)
}
if c.Slug != nil {
saved.Slug = *c.Slug
if saved.Slug == "" {
saved.Slug = article.GenerateSlug(saved.Title)
}
}
return saved
}
func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.SEOSlugCheck, error) {
available := article.SEOSlugCheck{Available: true}
if candidate.Slug == "" {
return available, nil
}
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Slug, candidate.ID)
if err != nil || !taken {
return available, err
}
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Slug, candidate.ID, firstSuggestedAttempt)
if err != nil {
return article.SEOSlugCheck{}, err
}
return article.SEOSlugCheck{Suggestion: &suggestion}, nil
}
@@ -25,6 +25,7 @@ type ListArticlesCommand struct {
Status string
Sort article.Sort
SiteKey *string
SEO string
}
type ArticlePage struct {
@@ -33,6 +34,7 @@ type ArticlePage struct {
Page int
Size int
Counts article.Counts
SEO map[uint32]article.SEOSummary
}
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
@@ -40,7 +42,7 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
if err != nil {
return ArticlePage{}, err
}
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts}
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
if !command.Verified && command.Status == article.StatusDraft {
return page, nil
}
@@ -55,19 +57,69 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
}
filters.SiteID = &siteID
}
filters.Unpaged = command.SEO != ""
found, err := u.articleRepository.FindPage(filters)
if err != nil {
return ArticlePage{}, err
}
rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
summaries, err := u.summarize(found.Items)
if err != nil {
return ArticlePage{}, err
}
items, total := found.Items, found.Total
if command.SEO != "" {
items, total = pageBySEOStatus(found.Items, summaries, command)
}
rendered, err := renderArticles(u.bodyRenderer, items, command.Format)
if err != nil {
return ArticlePage{}, err
}
page.Items = rendered
page.Total = found.Total
page.Total = total
page.SEO = summaries
return page, nil
}
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
matching := make([]article.Article, 0, len(items))
for _, item := range items {
if summaries[item.ID].Status == command.SEO {
matching = append(matching, item)
}
}
size := min(max(command.Size, 0), len(matching))
if size == 0 {
return []article.Article{}, len(matching)
}
pages := (len(matching) + size - 1) / size
pageIndex := max(command.Page, 1) - 1
if pageIndex >= pages {
return []article.Article{}, len(matching)
}
start := pageIndex * size
end := min(start+size, len(matching))
return matching[start:end], len(matching)
}
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
summaries := make(map[uint32]article.SEOSummary, len(items))
if len(items) == 0 {
return summaries, nil
}
known, err := u.sites.List()
if err != nil {
return nil, err
}
owners := make(map[uint32]site.Site, len(known))
for _, owner := range known {
owners[owner.ID] = owner
}
for _, item := range items {
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
}
return summaries, nil
}
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
found, err := u.sites.GetByKey(key)
if errors.Is(err, site.ErrSiteNotFound) {
@@ -0,0 +1,50 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"time"
)
type ListSitemapUseCase struct {
articleRepository article.Repository
sites site.Repository
}
func NewListSitemapUseCase(articleRepository article.Repository, sites site.Repository) *ListSitemapUseCase {
return &ListSitemapUseCase{articleRepository: articleRepository, sites: sites}
}
type SitemapEntry struct {
URL string
ModifiedAt time.Time
Noindex bool
}
type SitemapPage struct {
Entries []SitemapEntry
Total int
}
func (u *ListSitemapUseCase) List(siteKey string, page int, size int) (SitemapPage, error) {
owner, err := u.sites.GetByKey(siteKey)
if err != nil {
return SitemapPage{}, err
}
online := true
found, err := u.articleRepository.FindPage(article.Filters{
IsOnline: &online,
SiteID: &owner.ID,
Page: page,
Size: size,
Sort: article.Sort{Field: article.SortByID, Direction: article.SortAsc},
})
if err != nil {
return SitemapPage{}, err
}
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})
}
return SitemapPage{Entries: entries, Total: found.Total}, nil
}
@@ -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),
}
}
+101
View File
@@ -0,0 +1,101 @@
package useCases
import (
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
type bodyFacts struct {
spacedText string
textRunes int
hasSubheading bool
imagesWithoutAlt int
}
var blockElements = map[string]bool{
"p": true, "div": true, "br": true, "li": true, "ul": true, "ol": true, "blockquote": true, "pre": true,
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "tr": true, "section": true, "article": true,
"figure": true, "figcaption": true, "table": true, "hr": true,
}
var subheadings = map[string]bool{"h2": true, "h3": true, "h4": true, "h5": true, "h6": true}
var skippedElements = map[string]bool{"script": true, "style": true}
const sentenceTerminators = ".!?"
func analyzeBody(body string) bodyFacts {
tokenizer := html.NewTokenizer(strings.NewReader(body))
var plain, spaced strings.Builder
facts := bodyFacts{}
skipping := false
for {
kind := tokenizer.Next()
if kind == html.ErrorToken {
break
}
switch kind {
case html.TextToken:
if !skipping {
text := string(tokenizer.Text())
plain.WriteString(text)
spaced.WriteString(text)
}
case html.StartTagToken, html.SelfClosingTagToken, html.EndTagToken:
token := tokenizer.Token()
skipping = nextSkipping(skipping, kind, token)
if blockElements[token.Data] {
spaced.WriteString(" ")
}
if kind == html.EndTagToken {
continue
}
if subheadings[token.Data] {
facts.hasSubheading = true
}
if token.Data == "img" && !hasAlt(token) {
facts.imagesWithoutAlt++
}
}
}
facts.textRunes = utf8.RuneCountInString(strings.TrimSpace(plain.String()))
facts.spacedText = strings.Join(strings.Fields(spaced.String()), " ")
return facts
}
func nextSkipping(skipping bool, kind html.TokenType, token html.Token) bool {
if !skippedElements[token.Data] {
return skipping
}
switch kind {
case html.StartTagToken:
return true
case html.EndTagToken:
return false
}
return skipping
}
func hasAlt(token html.Token) bool {
for _, attribute := range token.Attr {
if attribute.Key == "alt" {
return strings.TrimSpace(attribute.Val) != ""
}
}
return false
}
func firstSentence(text string) string {
runes := []rune(text)
for index, character := range runes {
if !strings.ContainsRune(sentenceTerminators, character) {
continue
}
if index+1 == len(runes) || runes[index+1] == ' ' {
return string(runes[:index+1])
}
}
return text
}
@@ -0,0 +1,97 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"slices"
"strings"
"unicode/utf8"
)
func metaTitle(art article.Article) (string, string) {
if art.SEOTitle != "" {
return art.SEOTitle, article.SEOSourceSEOTitle
}
return art.Title, article.SEOSourceTitle
}
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts) (string, string) {
if art.SEODescription != "" {
return art.SEODescription, article.SEOSourceSEODescription
}
if art.Excerpt != "" {
return art.Excerpt, article.SEOSourceExcerpt
}
if sentence := firstSentence(facts.spacedText); sentence != "" {
return sentence, article.SEOSourceBody
}
if owner.DefaultDescription != "" {
return owner.DefaultDescription, article.SEOSourceSite
}
return "", article.SEOSourceNone
}
func suggestion(code string, field string, params map[string]int) article.SEOSuggestion {
return article.SEOSuggestion{Code: code, Field: field, Params: params}
}
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []article.SEOSuggestion {
suggestions := []article.SEOSuggestion{}
title, _ := metaTitle(art)
titleRunes := utf8.RuneCountInString(title)
if titleRunes > article.SEOTitleMaxRunes {
suggestions = append(suggestions, suggestion(article.DismissTitleLong, article.SEOFieldTitle, map[string]int{"length": titleRunes, "max": article.SEOTitleMaxRunes}))
}
if strings.TrimSpace(title) == "" {
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
}
description, _ := resolveDescription(art, owner, facts)
descriptionRunes := utf8.RuneCountInString(description)
if descriptionRunes < article.SEODescriptionMinRunes {
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
}
if descriptionRunes > article.SEODescriptionMaxRunes {
suggestions = append(suggestions, suggestion(article.DismissDescLong, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "max": article.SEODescriptionMaxRunes}))
}
if art.CoverImageID != nil && strings.TrimSpace(art.CoverImageAlt) == "" {
suggestions = append(suggestions, suggestion(article.DismissCoverAlt, article.SEOFieldCoverAlt, map[string]int{}))
}
if art.CoverImageID == nil {
suggestions = append(suggestions, suggestion(article.DismissCoverNone, article.SEOFieldCoverID, map[string]int{}))
}
slugRunes := utf8.RuneCountInString(art.Slug)
if slugRunes > article.SEOSlugMaxRunes {
suggestions = append(suggestions, suggestion(article.DismissSlugLong, article.SEOFieldSlug, map[string]int{"length": slugRunes, "max": article.SEOSlugMaxRunes}))
}
if len(art.Authors) == 0 {
suggestions = append(suggestions, suggestion(article.DismissNoAuthor, article.SEOFieldAuthors, map[string]int{}))
}
if facts.textRunes >= article.SEOHeadingMinTextRunes && !facts.hasSubheading {
suggestions = append(suggestions, suggestion(article.DismissNoHeading, article.SEOFieldBody, map[string]int{}))
}
if facts.imagesWithoutAlt > 0 {
suggestions = append(suggestions, suggestion(article.DismissBodyAlt, article.SEOFieldBody, map[string]int{"count": facts.imagesWithoutAlt}))
}
if facts.textRunes < article.SEOShortBodyRunes {
suggestions = append(suggestions, suggestion(article.DismissShort, article.SEOFieldBody, map[string]int{"length": facts.textRunes, "min": article.SEOShortBodyRunes}))
}
return suggestions
}
func assessSEO(art article.Article, owner site.Site) article.SEOAssessment {
facts := analyzeBody(art.Body)
kept := []article.SEOSuggestion{}
for _, found := range rawSuggestions(art, owner, facts) {
if !slices.Contains(art.SEODismissed, found.Code) {
kept = append(kept, found)
}
}
status := article.SEOStatusReady
if len(kept) > 0 {
status = article.SEOStatusSuggestions
}
if art.Noindex {
status = article.SEOStatusHidden
}
return article.SEOAssessment{Status: status, Suggestions: kept}
}