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.