From c765c71c21bf10b7242dca1d08217f0acc1ce2d3 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 13 Aug 2026 18:41:15 -0400 Subject: [PATCH] Build the default footer from the taxonomy The footer's default was a hand-written list of every section and subsection, which meant every taxonomy change had to be made twice. It had already drifted: it still listed Cooking, Books and Listicles under Entertainment, and knew nothing about Food or TV. The shape stays hand-written -- Columns stacked under Opinion, Special Editions under Comics & Puzzles, six columns -- because that is a layout decision no data answers. Only the links inside are generated. One layer deep, matching the section strip: a section's heading plus its direct visible subsections. The tree is three levels now, so recursing would print Beer Reviews, Wine Reviews and Restaurant Reviews under A&E and grow without bound. is_visible drives both the strip and the footer, so hiding a subsection in the sections screen now removes it from both in one action. Two blocks stay literal because they are not taxonomy. The About column is site furniture. The Special Editions block reads like taxonomy but is not: three of its four links deliberately point away from their own page -- The Rectangle is an external site, Welcome Week a search URL, 100 Year Anniversary a bespoke page -- and Graduation is a section that appears in no other column. Cached with the cms_settings TTL, since the footer renders on every page and the stored setting is absent by default; without it every request would run a taxonomy query, which is the load that queued in front of the database on 2026-08-06. A taxonomy write drops it. An unreadable or empty taxonomy falls back to the old static columns rather than rendering an empty nav. Co-Authored-By: Claude Opus 5 --- server/internal/database/footer_settings.go | 235 +++++++++++++++++- .../internal/database/footer_settings_test.go | 52 +++- .../database/taxonomy_integration_test.go | 107 ++++++++ server/internal/handlers/taxonomy.go | 4 + 4 files changed, 389 insertions(+), 9 deletions(-) diff --git a/server/internal/database/footer_settings.go b/server/internal/database/footer_settings.go index 1290f24..795ed68 100644 --- a/server/internal/database/footer_settings.go +++ b/server/internal/database/footer_settings.go @@ -5,6 +5,8 @@ import ( "database/sql" "encoding/json" "strings" + "sync" + "time" "server/internal/models" ) @@ -14,10 +16,183 @@ import ( // whole by the settings screen, and it never needs to be queried by parts. const footerSettingKey = "footer_menu" -// defaultFooterColumns mirrors the footer the public site shipped hardcoded, so -// an untouched install serves exactly what it served before and the settings -// screen opens pre-populated instead of blank. -func defaultFooterColumns() []models.FooterColumn { +// footerPart is one group inside a generated column: either a taxonomy section, +// rendered as its heading plus its visible subsections, or a literal run of +// entries for the things the taxonomy does not describe. +type footerPart struct { + Section string + Entries []models.FooterEntry +} + +// footerTemplate is the SHAPE of the footer -- which sections share a column and +// in what order -- with the links themselves left to the taxonomy. +// +// The shape stays hand-written because it is a layout decision that no data +// answers: Columns is stacked under Opinion and Special Editions under Comics & +// Puzzles to keep the footer at six columns, and a rule like "one column per +// section" would widen it to eight the moment somebody adds a section. +// +// The two literal blocks are the entries that are not taxonomy at all. The About +// column is site furniture. The Special Editions block is subtler: those rows DO +// exist in the taxonomy now, but three of the four deliberately point somewhere +// other than their own page -- The Rectangle is an external site, Welcome Week is +// a search URL, 100 Year Anniversary is a bespoke page -- and Graduation is a +// section of its own that appears in no other column. Generating them would +// quietly redirect four working links. +func footerTemplate() [][]footerPart { + link := func(label, href string) models.FooterEntry { + return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href} + } + external := func(label, href string) models.FooterEntry { + return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href, NewTab: true} + } + heading := func(label, href string) models.FooterEntry { + return models.FooterEntry{Kind: models.FooterEntryHeading, Label: label, Href: href} + } + + return [][]footerPart{ + {{Entries: []models.FooterEntry{ + heading("About", "/about"), + link("Contact Us", "/contact"), + external("Join The Triangle", "https://docs.google.com/forms/d/e/1FAIpQLScra_6sUenvmpIuQ5FjmMyWO0a2sz9z36HkrqfnYQvJGH9BGQ/viewform"), + link("Staff", "/staff"), + link("Find-A-Triangle", "/find"), + link("Photo Gallery", "/photo"), + external("Print Archive", "https://drexel.primo.exlibrisgroup.com/discovery/collectionDiscovery?vid=01DRXU_INST:01DRXU&inst=01DRXU_INST&collectionId=81448731180004721"), + link("Constitution", "/proxy/wp-content/uploads/2026/03/The-Triangle-Constitution-3.pdf"), + }}}, + {{Section: "news"}}, + {{Section: "sports"}}, + {{Section: "opinion"}, {Section: "columns"}}, + {{Section: "entertainment"}}, + {{Section: "comics-puzzles"}, {Entries: []models.FooterEntry{ + heading("Special Editions", "/"), + link("Graduation", "/graduation"), + link("Welcome Week", "/search?s=Welcome%20Week"), + external("The Rectangle", "https://therectangle.org"), + link("100 Year Anniversary", "/one-hundred"), + }}}, + } +} + +// footerTaxonomy is the section tree the footer needs: each section's title, and +// the visible subsections directly under it, in strip order. +type footerTaxonomy struct { + titles map[string]string + children map[string][]models.FooterEntry +} + +// loadFooterTaxonomy reads the sections and their DIRECT visible subsections. +// +// Direct only. The tree is three levels deep now -- Beer Reviews hangs under +// Food, which hangs under A&E -- and a footer that recursed would list the whole +// archive. One layer is what the section strip shows, so the footer matches the +// page it links to. +// +// is_visible filters both, which is what makes the footer stop being a separate +// thing to maintain: hiding a subsection in the sections screen removes it from +// the strip and from the footer in one action. +func loadFooterTaxonomy(ctx context.Context, conn *sql.DB) (footerTaxonomy, error) { + loaded := footerTaxonomy{ + titles: map[string]string{}, + children: map[string][]models.FooterEntry{}, + } + if conn == nil { + return loaded, nil + } + + rows, err := conn.QueryContext(ctx, ` + SELECT kind, slug, canonical_title, COALESCE(parent_slug, '') + FROM site_taxonomy + WHERE kind IN ('section', 'subsection') AND is_visible = 1 + ORDER BY id ASC + `) + if err != nil { + return footerTaxonomy{}, err + } + defer rows.Close() + + for rows.Next() { + var kind, slug, title, parent string + if err := rows.Scan(&kind, &slug, &title, &parent); err != nil { + return footerTaxonomy{}, err + } + slug = strings.TrimSpace(slug) + title = strings.TrimSpace(title) + if slug == "" || title == "" { + continue + } + if kind == "section" { + loaded.titles[slug] = title + continue + } + if parent = strings.TrimSpace(parent); parent != "" { + loaded.children[parent] = append(loaded.children[parent], models.FooterEntry{ + Kind: models.FooterEntryLink, + Label: title, + Href: "/" + slug, + }) + } + } + return loaded, rows.Err() +} + +// buildFooterColumns renders footerTemplate against the taxonomy. +// +// Falls back to the static columns whenever the taxonomy cannot supply the +// sections -- an unreadable table, or one that has not been imported yet. The +// footer is on every page, so "render the old links" beats "render an empty +// nav" by a wide margin. +func buildFooterColumns(ctx context.Context, conn *sql.DB) []models.FooterColumn { + loaded, err := loadFooterTaxonomy(ctx, conn) + if err != nil || len(loaded.titles) == 0 { + return staticFooterColumns() + } + + columns := make([]models.FooterColumn, 0, len(footerTemplate())) + for _, parts := range footerTemplate() { + entries := make([]models.FooterEntry, 0, 12) + for _, part := range parts { + rendered := part.Entries + if part.Section != "" { + title, known := loaded.titles[part.Section] + if !known { + // A section the template names but the taxonomy does not + // have. Skipping the group keeps the rest of the column, + // rather than emitting a heading that links nowhere. + continue + } + rendered = append([]models.FooterEntry{{ + Kind: models.FooterEntryHeading, + Label: title, + Href: "/" + part.Section, + }}, loaded.children[part.Section]...) + } + if len(rendered) == 0 { + continue + } + // The blank line between stacked groups, added only between them so + // a column never opens or closes on a spacer. + if len(entries) > 0 { + entries = append(entries, models.FooterEntry{Kind: models.FooterEntrySpacer}) + } + entries = append(entries, rendered...) + } + if len(entries) > 0 { + columns = append(columns, models.FooterColumn{Entries: entries}) + } + } + + if len(columns) == 0 { + return staticFooterColumns() + } + return columns +} + +// staticFooterColumns mirrors the footer the public site shipped hardcoded. It +// is now only the fallback for when the taxonomy cannot be read; the live +// default is generated from it by buildFooterColumns. +func staticFooterColumns() []models.FooterColumn { link := func(label, href string) models.FooterEntry { return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href} } @@ -93,6 +268,50 @@ func defaultFooterColumns() []models.FooterColumn { } } +// generatedFooter caches the columns built from the taxonomy. +// +// The footer renders on every page and the stored setting is absent by default, +// so without this every request would run a taxonomy query -- the exact shape of +// load that put a queue in front of the database on 2026-08-06. It shares +// settingsCacheTTL with cms_settings, since it is the same kind of value: read +// constantly, changed a few times a month. +var ( + generatedFooterMu sync.RWMutex + generatedFooterColumns []models.FooterColumn + generatedFooterExpires time.Time +) + +// defaultFooterColumns is the footer served when nothing is stored: generated +// from the taxonomy, cached, and falling back to the static columns. +func defaultFooterColumns(ctx context.Context, conn *sql.DB) []models.FooterColumn { + ttl := settingsCacheTTL() + if ttl > 0 { + generatedFooterMu.RLock() + cached, expires := generatedFooterColumns, generatedFooterExpires + generatedFooterMu.RUnlock() + if cached != nil && time.Now().Before(expires) { + return cached + } + } + + columns := buildFooterColumns(ctx, conn) + if ttl > 0 { + generatedFooterMu.Lock() + generatedFooterColumns, generatedFooterExpires = columns, time.Now().Add(ttl) + generatedFooterMu.Unlock() + } + return columns +} + +// InvalidateGeneratedFooter drops the cached columns, so a taxonomy edit shows +// up in the footer on the next request rather than up to a TTL later. Called +// from the same place that refreshes the other taxonomy-derived state. +func InvalidateGeneratedFooter() { + generatedFooterMu.Lock() + generatedFooterColumns, generatedFooterExpires = nil, time.Time{} + generatedFooterMu.Unlock() +} + // GetFooterSettings returns the stored footer menu, falling back to the // built-in default when the key is absent, blank, unparseable, or stores an // empty menu. The public footer is not something that should ever render empty @@ -103,19 +322,19 @@ func GetFooterSettings(ctx context.Context, conn *sql.DB) (models.FooterSettings return models.FooterSettings{}, err } if !found { - return models.FooterSettings{Columns: defaultFooterColumns()}, nil + return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil } if strings.TrimSpace(raw) == "" { - return models.FooterSettings{Columns: defaultFooterColumns()}, nil + return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil } var parsed models.FooterSettings if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - return models.FooterSettings{Columns: defaultFooterColumns()}, nil + return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil } parsed.Columns = normalizeFooterColumns(parsed.Columns) if len(parsed.Columns) == 0 { - return models.FooterSettings{Columns: defaultFooterColumns()}, nil + return models.FooterSettings{Columns: defaultFooterColumns(ctx, conn)}, nil } return parsed, nil } diff --git a/server/internal/database/footer_settings_test.go b/server/internal/database/footer_settings_test.go index b7b7fb1..c0566c1 100644 --- a/server/internal/database/footer_settings_test.go +++ b/server/internal/database/footer_settings_test.go @@ -1,6 +1,7 @@ package database import ( + "context" "testing" "server/internal/models" @@ -76,7 +77,7 @@ func TestNormalizeFooterColumns_StripsSpacerContent(t *testing.T) { // The default menu is what an untouched install serves, so it must survive // normalization unchanged. func TestDefaultFooterColumns_SurviveNormalization(t *testing.T) { - defaults := defaultFooterColumns() + defaults := staticFooterColumns() normalized := normalizeFooterColumns(defaults) if len(normalized) != len(defaults) { @@ -88,3 +89,52 @@ func TestDefaultFooterColumns_SurviveNormalization(t *testing.T) { } } } + +// TestBuildFooterColumns_FallsBackWithoutTaxonomy is the safety net: the footer +// is on every page, so a taxonomy it cannot read has to leave the old links +// standing rather than render an empty nav. +func TestBuildFooterColumns_FallsBackWithoutTaxonomy(t *testing.T) { + got := buildFooterColumns(context.Background(), nil) + + want := staticFooterColumns() + if len(got) != len(want) { + t.Fatalf("got %d columns, want the %d static ones", len(got), len(want)) + } + if got[0].Entries[0].Label != "About" { + t.Errorf("first entry = %q, want the static About heading", got[0].Entries[0].Label) + } +} + +// TestFooterTemplate_KeepsTheNonTaxonomyLinks guards the entries that must NOT +// be generated. Three of the Special Editions links deliberately point away +// from their own taxonomy page, and Graduation is a section that appears in no +// other column, so generating that block would quietly redirect four links. +func TestFooterTemplate_KeepsTheNonTaxonomyLinks(t *testing.T) { + var literals []models.FooterEntry + for _, column := range footerTemplate() { + for _, part := range column { + if part.Section == "" { + literals = append(literals, part.Entries...) + } + } + } + + for _, want := range []struct{ label, href string }{ + {"The Rectangle", "https://therectangle.org"}, + {"Welcome Week", "/search?s=Welcome%20Week"}, + {"100 Year Anniversary", "/one-hundred"}, + {"Graduation", "/graduation"}, + {"Contact Us", "/contact"}, + } { + found := false + for _, entry := range literals { + if entry.Label == want.label && entry.Href == want.href { + found = true + break + } + } + if !found { + t.Errorf("%q -> %q is no longer a literal footer entry; generating it would change where it points", want.label, want.href) + } + } +} diff --git a/server/internal/database/taxonomy_integration_test.go b/server/internal/database/taxonomy_integration_test.go index ea44e5e..ea997ab 100644 --- a/server/internal/database/taxonomy_integration_test.go +++ b/server/internal/database/taxonomy_integration_test.go @@ -8,6 +8,8 @@ import ( "testing" _ "github.com/go-sql-driver/mysql" + + "server/internal/models" ) // A section the seed has no defaults for, for the "keeps no aliases" case. @@ -507,3 +509,108 @@ func TestEntertainmentVisibilitySeedRunsOnce(t *testing.T) { t.Errorf("books is_visible = %d after a restart, want 1 -- the seed re-ran over an editor's change", got) } } + +// TestFooterDefaultFollowsTheTaxonomy is the point of generating the footer: +// the desk curates the section strip, and the footer follows without anyone +// editing a second list. +// +// It covers the three things that make it "one layer of depth": a section's +// direct children are listed, a hidden one is not, and a grandchild is not +// either -- the tree is three levels now, and a footer that recursed would +// print the archive. +func TestFooterDefaultFollowsTheTaxonomy(t *testing.T) { + conn := taxonomyTestDB(t) + ctx := context.Background() + + if err := EnsureSettingsTable(ctx, conn); err != nil { + t.Fatalf("ensure settings table: %v", err) + } + if err := EnsureTaxonomyTable(ctx, conn); err != nil { + t.Fatalf("ensure taxonomy table: %v", err) + } + t.Cleanup(InvalidateGeneratedFooter) + + insertTaxonomyRow(t, conn, 1, "section", "entertainment", "Entertainment") + // Movies is linked, Books is hidden, and Beer Reviews is a grandchild under + // the visible Food row. + for _, row := range []struct { + id int64 + slug string + title string + parent string + visible int + }{ + {2, "movies", "Movies", "entertainment", 1}, + {3, "books", "Books", "entertainment", 0}, + {4, "food", "Food", "entertainment", 1}, + {5, "beer-reviews", "Beer Reviews", "food", 1}, + } { + insertTaxonomyRow(t, conn, row.id, "subsection", row.slug, row.title) + if _, err := conn.ExecContext(ctx, + "UPDATE site_taxonomy SET parent_slug = ?, is_visible = ? WHERE slug = ?", + row.parent, row.visible, row.slug, + ); err != nil { + t.Fatalf("place %s: %v", row.slug, err) + } + } + + labels := func() []string { + t.Helper() + settings, err := GetFooterSettings(ctx, conn) + if err != nil { + t.Fatalf("get footer settings: %v", err) + } + var found []string + for _, column := range settings.Columns { + for _, entry := range column.Entries { + found = append(found, entry.Label) + } + } + return found + } + + got := labels() + has := func(label string) bool { + for _, entry := range got { + if entry == label { + return true + } + } + return false + } + + if !has("Movies") || !has("Food") { + t.Errorf("footer = %v, want the visible subsections listed", got) + } + if has("Books") { + t.Error("a hidden subsection reached the footer; the strip toggle has to drive both") + } + if has("Beer Reviews") { + t.Error("a grandchild reached the footer; the footer is one layer deep") + } + // The literal blocks survive alongside the generated ones. + if !has("The Rectangle") || !has("Contact Us") { + t.Errorf("footer = %v, want the non-taxonomy links kept", got) + } + + // Hiding Movies removes it, once the cached columns are dropped the way a + // taxonomy write does. + if _, err := conn.ExecContext(ctx, "UPDATE site_taxonomy SET is_visible = 0 WHERE slug = 'movies'"); err != nil { + t.Fatalf("hide movies: %v", err) + } + InvalidateGeneratedFooter() + if got = labels(); has("Movies") { + t.Errorf("footer = %v, want Movies gone after it was hidden", got) + } + + // A stored menu still wins: generating is the DEFAULT, not an override of + // whatever an editor saved in the settings screen. + if err := SetFooterSettings(ctx, conn, models.FooterSettings{Columns: []models.FooterColumn{ + {Entries: []models.FooterEntry{{Kind: models.FooterEntryLink, Label: "Only This", Href: "/only"}}}, + }}); err != nil { + t.Fatalf("store a custom footer: %v", err) + } + if got = labels(); len(got) != 1 || got[0] != "Only This" { + t.Errorf("footer = %v, want the stored menu to win over the generated default", got) + } +} diff --git a/server/internal/handlers/taxonomy.go b/server/internal/handlers/taxonomy.go index 3e541f4..26946b6 100644 --- a/server/internal/handlers/taxonomy.go +++ b/server/internal/handlers/taxonomy.go @@ -102,6 +102,10 @@ func refreshTaxonomyDerivedState(r *http.Request, conn *sql.DB, slugs ...string) if err := db.RebuildTaxonomyArticleCountsFor(r.Context(), conn, slugs...); err != nil { slog.Error("failed to recount taxonomy articles after a write", "slugs", slugs, "error", err) } + // The public footer's default is built from this same tree, so a renamed + // section or a toggled subsection has to drop those cached columns too -- + // otherwise the sections screen and the footer disagree for up to a TTL. + db.InvalidateGeneratedFooter() } // parentSlugForRecount narrows validateTaxonomyParent's any-typed result to the