From f540f7402a13f560428080e4d6af7905e44992f0 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 13 Aug 2026 00:16:37 -0400 Subject: [PATCH] Let a category label link to its own page, not its section's The map that turns a category label into a link was built from category_aliases alone, first writer wins. /v1/taxonomy comes back ordered by kind, so every section is written before every subsection, and a section's alias beat a subsection's own title. An alias exists so a section can absorb a category that has no page of its own; it should never outrank that category once it has one. Index canonical_title first and let aliases fill only the titles no row claims, which is the precedence the CMS applies to the same question in RefreshCategoryAliases. Matching it is the point: the CMS sends a resolved slug on every category, and the fallback here should not disagree with it about where a label leads. On today's data nothing moves -- the CMS-sent slug is routable, so getCategoryHref short-circuits before reaching the map. The difference shows when it does not, which an edge-cached response carrying a pre-seed slug will do: "Men's Lacrosse" resolves to /mens-lacrosse rather than /sports. Genuine aliases are unaffected, and "Arts & Entertainment" still resolves to /entertainment. Co-Authored-By: Claude Opus 5 --- src/utils/taxonomyStore.ts | 54 ++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/utils/taxonomyStore.ts b/src/utils/taxonomyStore.ts index f4f3295..3672e82 100644 --- a/src/utils/taxonomyStore.ts +++ b/src/utils/taxonomyStore.ts @@ -38,17 +38,29 @@ type SlugKinds = Map; /** * Category title (lowercased) -> the routable slug that owns it. * - * The CMS records the category titles an imported article carries for a section - * it does not name directly: Columns owns "Podcasts", Entertainment owns - * "Arts & Entertainment", Sports owns "Men's Lacrosse" and a dozen more. - * Article pages label themselves with the raw category, so this is what turns - * that label into a link that goes somewhere. + * Article pages label themselves with the raw category the import gave them, so + * this is what turns that label into a link that goes somewhere. Two things put + * a title here, and the order between them matters. + * + * A row's OWN title comes first. An alias is second, and only fills a title no + * row claims: aliases exist so a section can absorb a category that has no page + * of its own ("Arts & Entertainment" -> /entertainment), so an alias must never + * outrank that category once it does have a page. Sports still aliases "Men's + * Lacrosse", and there is now a Men's Lacrosse subsection; the label belongs to + * the subsection. + * + * This is the same precedence the CMS applies when it answers the same question + * (RefreshCategoryAliases in triangle-cms), and matching it is the point -- the + * CMS sends a resolved slug on every category, and the two must agree about + * what that slug should be. Indexing aliases alone got it backwards silently, + * because /sports is a real page that really does list the article, so the + * wrong link looked exactly like the right one. */ -type AliasOwners = Map; +type CategoryOwners = Map; type Taxonomy = { kinds: SlugKinds; - aliases: AliasOwners; + owners: CategoryOwners; }; type CacheEntry = { @@ -63,7 +75,8 @@ let inFlight: Promise | null = null; function indexTaxonomy(items: TaxonomyItem[]): Taxonomy { const kinds: SlugKinds = new Map(); - const aliases: AliasOwners = new Map(); + const owners: CategoryOwners = new Map(); + const aliased: CategoryOwners = new Map(); for (const item of items) { if (item?.type !== 'section' && item?.type !== 'subsection') continue; @@ -74,15 +87,26 @@ function indexTaxonomy(items: TaxonomyItem[]): Taxonomy { kinds.set(item.slug, item.type); } + if (typeof item.canonical_title === 'string') { + const title = item.canonical_title.trim().toLowerCase(); + // First writer wins within a tier, so a title two rows share cannot make + // the resolved slug depend on the order the CMS listed them in. + if (title && !owners.has(title)) owners.set(title, item.slug); + } + for (const alias of item.category_aliases ?? []) { if (typeof alias !== 'string') continue; const key = alias.trim().toLowerCase(); - // Same precedence: the first owner of a title keeps it. - if (key && !aliases.has(key)) aliases.set(key, item.slug); + if (key && !aliased.has(key)) aliased.set(key, item.slug); } } - return { kinds, aliases }; + // Aliases fill only the titles no row claims as its own. + for (const [title, slug] of aliased) { + if (!owners.has(title)) owners.set(title, slug); + } + + return { kinds, owners }; } async function fetchTaxonomy(): Promise { @@ -175,8 +199,10 @@ type ArticleCategory = { name?: string; slug?: string }; * regardless, so every one of those articles shipped a link to a 404. * * Resolution order: the slug itself if it is routable, then the category title - * against the taxonomy's alias map ("Podcasts" -> /columns). Null means the - * caller should render the label as plain text rather than a dead link. + * against the taxonomy's owner map ("Podcasts" -> /podcasts, and + * "Arts & Entertainment" -> /entertainment, which has no page of its own). + * Null means the caller should render the label as plain text rather than a + * dead link. */ export async function getCategoryHref(category: ArticleCategory | undefined): Promise { if (!category) return null; @@ -189,6 +215,6 @@ export async function getCategoryHref(category: ArticleCategory | undefined): Pr if (category.slug && taxonomy.kinds.has(category.slug)) return `/${category.slug}`; - const owner = category.name && taxonomy.aliases.get(category.name.trim().toLowerCase()); + const owner = category.name && taxonomy.owners.get(category.name.trim().toLowerCase()); return owner ? `/${owner}` : null; }