diff --git a/server/internal/database/seo.go b/server/internal/database/seo.go index 9538d5e..6b7882d 100644 --- a/server/internal/database/seo.go +++ b/server/internal/database/seo.go @@ -118,9 +118,18 @@ func auditArticle(id int64, slug, title, seoTitle, metaDesc, focusKeyword, photo metaDesc = strings.TrimSpace(metaDesc) focusKeyword = strings.TrimSpace(focusKeyword) - if seoTitle == "" { + // A blank seo_title is not a defect: the public site renders the article + // title in its place (ArticleLayout.astro), so the page ships a correct + // either way. Only the effective title's length matters, because + // that is what search results actually truncate. + effectiveTitle := seoTitle + if effectiveTitle == "" { + effectiveTitle = strings.TrimSpace(title) + } + switch { + case effectiveTitle == "": add("warning", "Missing SEO title") - } else if utf8.RuneCountInString(seoTitle) > seoTitleMaxLen { + case utf8.RuneCountInString(effectiveTitle) > seoTitleMaxLen: add("warning", "SEO title exceeds 60 characters") } diff --git a/server/internal/database/seo_test.go b/server/internal/database/seo_test.go index 4836ab5..f33f317 100644 --- a/server/internal/database/seo_test.go +++ b/server/internal/database/seo_test.go @@ -89,6 +89,44 @@ func TestAuditArticleFlagsMissingFeaturedImage(t *testing.T) { } } +func TestAuditArticleFallsBackToTitleForSEOTitle(t *testing.T) { + longEnoughDesc := strings.Repeat("a", 100) + photoURL := "https://example.com/a.jpg" + + // The public site renders the article title when seo_title is blank, so a + // blank column with a usable title is not an issue worth reporting. + issues := auditArticle(1, "some-slug", "A short headline", "", longEnoughDesc, "keyword", photoURL, "An excerpt") + messages := make([]string, 0, len(issues)) + for _, issue := range issues { + messages = append(messages, issue.Issue) + } + if containsSubstring(messages, "Missing SEO title") { + t.Errorf("a blank seo_title with a title to fall back on should not be flagged, got %v", messages) + } + + // The length check has to follow the fallback, because an over-long + // headline is what gets truncated in search results. + longTitle := strings.Repeat("a", seoTitleMaxLen+1) + issues = auditArticle(1, "some-slug", longTitle, "", longEnoughDesc, "keyword", photoURL, "An excerpt") + messages = messages[:0] + for _, issue := range issues { + messages = append(messages, issue.Issue) + } + if !containsSubstring(messages, "SEO title exceeds") { + t.Errorf("an over-long fallback title should be flagged, got %v", messages) + } + + // With neither, the page has no title at all. + issues = auditArticle(1, "some-slug", " ", "", longEnoughDesc, "keyword", photoURL, "An excerpt") + messages = messages[:0] + for _, issue := range issues { + messages = append(messages, issue.Issue) + } + if !containsSubstring(messages, "Missing SEO title") { + t.Errorf("expected a missing-SEO-title issue when there is no title either, got %v", messages) + } +} + func TestAuditArticleFlagsNoDescriptionAndNoExcerpt(t *testing.T) { // The public site falls back to the excerpt when meta_description is blank, // so only the combination leaves the page with no description at all.