From 7a9a9b21f90c04143ff1ee372c3b60d8a87aa845 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Tue, 1 Sep 2026 17:44:52 -0400 Subject: [PATCH] Expand the Yoast title templates in seo_title WordPress stored an article's SEO title as a Yoast template rather than as finished text -- "%%title%% %%page%%", or a headline with "%%page%% %%sep%% %%sitename%%" appended -- and Yoast substituted the variables when it rendered the page. BackfillArticleSEOFromYoast copies that value into seo_title verbatim, and nothing substitutes it now, so the tokens reach the public site's , og:title and twitter:title as themselves: a 2022 crossword is "%%title%% %%page%%" in the browser tab, in search results and on every shared link. 79 of 10,190 articles in production hold one, all imported from the 2022-2023 Yoast era. Expand them, mirroring expandSeoVariables in Scalene's seoTemplate.ts so the CMS and the public site agree on what a template means. %%page%% expands to nothing, as it did under Yoast -- it numbers a paginated archive, and an article is one page -- and an unrecognised variable is dropped rather than printed. A template that says no more than the headline, which is what Yoast's default meant, is stored blank: the site renders the headline for a blank seo_title, the SEO audit already treats that as correct, and it keeps tracking the headline if an editor rewrites it. The pass runs on every start rather than behind the backfill's one-time flag, because the databases that already hold these templates were seeded before this existed and the backfill only ever fills blanks. It is idempotent: expanded text carries no variables, so a second pass matches nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tx3ywgRQLzzrZWNcSdtTpr --- server/internal/database/seo.go | 153 +++++++++++++++++++ server/internal/database/yoast_title_test.go | 131 ++++++++++++++++ server/main.go | 11 ++ 3 files changed, 295 insertions(+) create mode 100644 server/internal/database/yoast_title_test.go diff --git a/server/internal/database/seo.go b/server/internal/database/seo.go index 6b7882d..e6059c5 100644 --- a/server/internal/database/seo.go +++ b/server/internal/database/seo.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "regexp" "strings" "unicode/utf8" @@ -169,3 +170,155 @@ func hasFeaturedImage(photoURL string) bool { trimmed := strings.TrimSpace(photoURL) return trimmed != "" && trimmed != "-1" } + +// yoastVariablePattern matches a Yoast template variable: "%%title%%". +var yoastVariablePattern = regexp.MustCompile(`%%[a-zA-Z0-9_]+%%`) + +// yoastGluedSeparatorPattern finds a separator an editor typed with no space +// before the variable that follows it ("Pars pro Toto -%%sitename%%"). +var yoastGluedSeparatorPattern = regexp.MustCompile(`([-|\x{2013}\x{2014}])(%%)`) + +// yoastSeparatorRunPattern collapses separators a dropped variable has left +// sitting next to each other. +var yoastSeparatorRunPattern = regexp.MustCompile(`(?:\s*-\s*){2,}`) + +// HasYoastVariables reports whether a value still carries an unexpanded Yoast +// template variable. +func HasYoastVariables(value string) bool { + return yoastVariablePattern.MatchString(value) +} + +// ExpandYoastTitle turns a Yoast SEO title template into finished text. +// +// WordPress stored an article's SEO title as a template rather than as a title +// -- "%%title%% %%page%%", or a headline with "%%page%% %%sep%% %%sitename%%" +// appended -- and Yoast substituted the variables when it rendered the page. +// Nothing substitutes them here, so a template copied into seo_title verbatim +// reaches the public site's <title>, og:title and twitter:title as the literal +// tokens. Mirrors expandSeoVariables in Scalene's src/utils/seoTemplate.ts; the +// two are meant to agree. +// +// %%page%% expands to nothing, as it did under Yoast: it numbers the pages of a +// paginated archive, and an article is one page. A variable this corpus does +// not carry is dropped rather than left visible -- an unrecognised token is +// still a token, and printing it is the defect. +// +// Returns "" when nothing usable survives, which callers store as a blank +// seo_title: the public site then renders the headline, which is what Yoast's +// default template meant in the first place. +func ExpandYoastTitle(template, title, siteTitle, primaryCategory string) string { + if template == "" { + return "" + } + + spaced := yoastGluedSeparatorPattern.ReplaceAllString(template, "$1 $2") + + const sep = "-" + expanded := yoastVariablePattern.ReplaceAllStringFunc(spaced, func(token string) string { + switch strings.ToLower(strings.Trim(token, "%")) { + case "title": + return strings.TrimSpace(title) + case "sitename": + return strings.TrimSpace(siteTitle) + case "primary_category": + return strings.TrimSpace(primaryCategory) + case "sep": + return sep + default: + return "" + } + }) + + return tidyYoastSeparators(expanded, sep) +} + +// tidyYoastSeparators cleans up after a variable that expanded to nothing: the +// punctuation framing it is still there, so a template ending in %%sep%% leaves +// the title hanging on a dash and a dropped %%page%% leaves two separators in a +// row. Returns "" when only punctuation survived. +func tidyYoastSeparators(value, sep string) string { + collapsed := strings.Join(strings.Fields(value), " ") + collapsed = yoastSeparatorRunPattern.ReplaceAllString(collapsed, " "+sep+" ") + collapsed = strings.TrimSpace(strings.TrimPrefix(collapsed, sep)) + collapsed = strings.TrimSpace(strings.TrimSuffix(collapsed, sep)) + if collapsed == sep { + return "" + } + return collapsed +} + +// isRedundantSEOTitle reports whether an expanded title says no more than the +// public site would render on its own: the headline, or the headline with the +// publication name appended, which is the fallback the article page already +// builds (ArticleLayout.astro). +func isRedundantSEOTitle(expanded, title, siteTitle string) bool { + normalize := func(value string) string { + return strings.ToLower(strings.Join(strings.Fields(value), " ")) + } + candidate := normalize(expanded) + return candidate == "" || + candidate == normalize(title) || + candidate == normalize(title+" - "+siteTitle) +} + +// ExpandYoastTitleTemplates rewrites every seo_title still holding a Yoast +// template into the text Yoast would have rendered. +// +// Runs after the Yoast backfill rather than inside it: the backfill only fills +// blanks and records a one-time flag, so on a database seeded before this +// existed the templates are already in place and would never be revisited. +// Idempotent -- expanded text carries no variables, so a second pass matches +// nothing. +func ExpandYoastTitleTemplates(ctx context.Context, conn *sql.DB) (int, error) { + siteTitle, err := GetSiteTitle(ctx, conn) + if err != nil { + return 0, err + } + + rows, err := conn.QueryContext(ctx, ` + SELECT id, COALESCE(title, ''), COALESCE(seo_title, ''), + COALESCE(JSON_VALUE(categories, '$[0]'), '') + FROM articles + WHERE seo_title REGEXP '%%[a-zA-Z0-9_]+%%' + `) + if err != nil { + return 0, err + } + defer rows.Close() + + type rewrite struct { + id int64 + seoTitle string + } + var pending []rewrite + for rows.Next() { + var id int64 + var title, seoTitle, primaryCategory string + if err := rows.Scan(&id, &title, &seoTitle, &primaryCategory); err != nil { + return 0, err + } + expanded := ExpandYoastTitle(seoTitle, title, siteTitle, primaryCategory) + // Yoast's default template is the headline, so expanding it stores a + // custom SEO title that says what the article already says -- and then + // stops tracking the headline when an editor rewrites it. Blank means + // "use the headline", which is both what the template meant and what the + // SEO audit treats as fine (see auditArticle). + if isRedundantSEOTitle(expanded, title, siteTitle) { + expanded = "" + } + pending = append(pending, rewrite{id: id, seoTitle: expanded}) + } + if err := rows.Err(); err != nil { + return 0, err + } + + for _, row := range pending { + if _, err := conn.ExecContext(ctx, + "UPDATE articles SET seo_title = ? WHERE id = ?", row.seoTitle, row.id, + ); err != nil { + return 0, err + } + } + + return len(pending), nil +} diff --git a/server/internal/database/yoast_title_test.go b/server/internal/database/yoast_title_test.go new file mode 100644 index 0000000..0f677bb --- /dev/null +++ b/server/internal/database/yoast_title_test.go @@ -0,0 +1,131 @@ +package database + +import "testing" + +// Every template below is a real value from articles.seo_title in production, +// paired with its article's headline. +func TestExpandYoastTitleOnProductionTemplates(t *testing.T) { + const siteTitle = "The Triangle" + + cases := []struct { + name string + template string + title string + primaryCategory string + want string + }{ + { + name: "the default template is just the headline", + template: "%%title%% %%page%%", + title: "With You!", + want: "With You!", + }, + { + name: "a dropped page variable does not stall the separator", + template: "Where to Eat on Drexel University's Campus %%page%% %%sep%% %%sitename%%", + title: "Where to eat on Drexel's campus", + want: "Where to Eat on Drexel University's Campus - The Triangle", + }, + { + name: "a trailing page variable leaves the headline alone", + template: "Yung Lean's \"Stardust\" tour omits new tracks in favor of old hits %%page%%", + title: "Yung Lean's Stardust tour", + want: "Yung Lean's \"Stardust\" tour omits new tracks in favor of old hits", + }, + { + name: "a template ending in a separator does not hang on the dash", + template: "%%title%% %%page%% %%sep%%", + title: "Ready to dance? CRJ and Empress Of got you covered", + want: "Ready to dance? CRJ and Empress Of got you covered", + }, + { + name: "an editor-typed separator keeps its space", + template: "Drexel Unveils Stone Installation \"Pars pro Toto\" -%%sitename%%", + title: "Drexel unveils Pars pro Toto", + want: "Drexel Unveils Stone Installation \"Pars pro Toto\" - The Triangle", + }, + { + name: "the primary category is substituted like any other variable", + template: "%%title%% %%page%% %%sep%% %%primary_category%%", + title: "Ten public safety tips and resources for Drexel students", + primaryCategory: "News", + want: "Ten public safety tips and resources for Drexel students - News", + }, + { + name: "an unrecognised variable is dropped rather than printed", + template: "%%currentyear%% Real headline", + title: "Real headline", + want: "Real headline", + }, + { + name: "a template that is nothing but punctuation expands to nothing", + template: "%%page%% %%sep%%", + title: "A headline", + want: "", + }, + { + name: "text without variables is returned unchanged", + template: "A hand-written SEO title", + title: "A headline", + want: "A hand-written SEO title", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got := ExpandYoastTitle(testCase.template, testCase.title, siteTitle, testCase.primaryCategory) + if got != testCase.want { + t.Errorf("ExpandYoastTitle(%q) = %q, want %q", testCase.template, got, testCase.want) + } + if HasYoastVariables(got) { + t.Errorf("ExpandYoastTitle(%q) left a variable behind: %q", testCase.template, got) + } + }) + } +} + +// Expanding is not the same as rewriting: running the result back through has +// to leave it alone, or a restart would keep rewriting the same rows. +func TestExpandYoastTitleIsIdempotent(t *testing.T) { + once := ExpandYoastTitle("%%title%% %%page%% %%sep%% %%sitename%%", "A headline", "The Triangle", "") + twice := ExpandYoastTitle(once, "A headline", "The Triangle", "") + if once != twice { + t.Errorf("second pass changed the value: %q then %q", once, twice) + } +} + +func TestIsRedundantSEOTitleMatchesWhatTheSiteRendersAnyway(t *testing.T) { + const title = "Meet the faces behind the Drexel Affirmations meme account" + const siteTitle = "The Triangle" + + redundant := []string{ + "", + title, + title + " - " + siteTitle, + " " + title + " ", + } + for _, value := range redundant { + if !isRedundantSEOTitle(value, title, siteTitle) { + t.Errorf("%q says no more than the headline, expected it to be redundant", value) + } + } + + kept := []string{ + "Drexel Affirmations: the meme account explained", + title + " - News", + } + for _, value := range kept { + if isRedundantSEOTitle(value, title, siteTitle) { + t.Errorf("%q is an editor's own title, expected it to be kept", value) + } + } +} + +func TestHasYoastVariables(t *testing.T) { + if !HasYoastVariables("%%title%% %%page%%") { + t.Error("a template should be detected") + } + if HasYoastVariables("A 100%% real headline about 50% off") { + t.Error("percent signs that are not a variable should not match") + } +} diff --git a/server/main.go b/server/main.go index 8ebfbe1..b02f5de 100644 --- a/server/main.go +++ b/server/main.go @@ -203,6 +203,17 @@ func main() { slog.Error("failed to backfill article SEO from Yoast export", "error", err) os.Exit(1) } + // What that backfill copies is a Yoast *template* ("%%title%% %%page%%"), + // which WordPress substituted at render time and nothing substitutes now, so + // the tokens reached the public site's <title> and og:title verbatim. Runs + // on every start rather than behind the backfill's one-time flag: a database + // seeded before this existed already holds the templates. Idempotent. + if expanded, err := database.ExpandYoastTitleTemplates(context.Background(), db); err != nil { + slog.Error("failed to expand Yoast SEO title templates", "error", err) + os.Exit(1) + } else if expanded > 0 { + slog.Info("expanded Yoast SEO title templates", "articles", expanded) + } if err := database.EnsureTaxonomyTable(context.Background(), db); err != nil { slog.Error("failed to create taxonomy table", "error", err) os.Exit(1)