From f74d9ac34bbf036b40c39dc59c573987c391235e Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 15 Aug 2026 04:55:53 +0530 Subject: [PATCH] fix: add self-canonicals for sitemap routes Closes #86 - Register all nine public sitemap routes (home, discover, project-preview, tools, catalog-updates, changelog, about, privacy, terms) in PUBLIC_CANONICALS as the single source of truth for exact, extensionless self-canonicals. - Add a homepage self-canonical ('/') to the Next.js fallback page metadata. - Route about and catalog-updates canonicals through PUBLIC_CANONICALS so every sitemap URL's canonical is derived from the registry. - Build sitemap URLs from PUBLIC_CANONICALS so the sitemap and canonical cannot drift; the homepage entry now matches its canonical trailing slash. - Replace the sitemap test with a sitemap/canonical contract test asserting full parity (sitemap paths === registered canonicals), extensionless self-referential values, the seven named public surfaces, and that canonicalPath maps the Astro .html overlays to the registered canonicals. --- src/__tests__/sitemap.test.ts | 48 +++++++++++++++---- src/app/about/page.tsx | 4 +- src/app/catalog-updates/page.tsx | 3 +- src/app/page.tsx | 5 ++ src/app/sitemap.ts | 80 +++++++++++--------------------- src/lib/public-canonicals.ts | 22 +++++++++ 6 files changed, 96 insertions(+), 66 deletions(-) diff --git a/src/__tests__/sitemap.test.ts b/src/__tests__/sitemap.test.ts index aaa5d25..c00b8ba 100644 --- a/src/__tests__/sitemap.test.ts +++ b/src/__tests__/sitemap.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import sitemap from '@/app/sitemap'; -import { PUBLIC_CANONICALS } from '@/lib/public-canonicals'; +import { PUBLIC_CANONICALS, PUBLIC_CANONICAL_PATHS } from '@/lib/public-canonicals'; import { canonicalPath } from '../../landing-astro/src/lib/canonical'; const siteUrl = 'https://starboard.codevetter.com'; -describe('sitemap', () => { - it('advertises only real public routes', () => { +describe('sitemap / canonical contract', () => { + it('advertises only real public routes in canonical order', () => { expect(sitemap().map((entry) => entry.url)).toEqual([ - siteUrl, + `${siteUrl}/`, `${siteUrl}/discover`, `${siteUrl}/project-preview`, `${siteUrl}/tools`, @@ -21,12 +21,40 @@ describe('sitemap', () => { ]); }); - it('keeps every shared public route on an extensionless self-canonical', () => { + it('gives every sitemap URL an exact, extensionless self-canonical', () => { + // Every sitemap pathname must be a registered canonical, and every + // registered canonical must appear in the sitemap — no drift either way. const sitemapPaths = sitemap().map((entry) => new URL(entry.url).pathname); - expect(Object.values(PUBLIC_CANONICALS).every((path) => sitemapPaths.includes(path))).toBe( - true - ); - expect(canonicalPath('/index.html')).toBe('/'); - expect(canonicalPath('/changelog.html')).toBe('/changelog'); + expect(sitemapPaths.sort()).toEqual([...PUBLIC_CANONICAL_PATHS].sort()); + + // Each canonical is self-referential and carries no file extension. + for (const path of PUBLIC_CANONICAL_PATHS) { + expect(path).not.toMatch(/\.(html|md|php)$/); + expect(path === '/' || path.startsWith('/')).toBe(true); + expect(path.includes('//')).toBe(false); + } + }); + + it('covers the seven public surfaces named in the canonical contract', () => { + expect(PUBLIC_CANONICALS.home).toBe('/'); + expect(PUBLIC_CANONICALS.discover).toBe('/discover'); + expect(PUBLIC_CANONICALS.projectPreview).toBe('/project-preview'); + expect(PUBLIC_CANONICALS.tools).toBe('/tools'); + expect(PUBLIC_CANONICALS.changelog).toBe('/changelog'); + expect(PUBLIC_CANONICALS.privacy).toBe('/privacy'); + expect(PUBLIC_CANONICALS.terms).toBe('/terms'); + }); + + it('maps Astro .html overlays to the registered extensionless canonicals', () => { + // Astro-served static pages are overlaid into OpenNext assets as + // index.html / changelog.html. canonicalPath must produce the same path + // registered in PUBLIC_CANONICALS so the built canonical matches the + // sitemap entry exactly. + expect(canonicalPath('/index.html')).toBe(PUBLIC_CANONICALS.home); + expect(canonicalPath('/changelog.html')).toBe(PUBLIC_CANONICALS.changelog); + expect(canonicalPath('/discover.html')).toBe(PUBLIC_CANONICALS.discover); + expect(canonicalPath('/tools.html')).toBe(PUBLIC_CANONICALS.tools); + expect(canonicalPath('/privacy.html')).toBe(PUBLIC_CANONICALS.privacy); + expect(canonicalPath('/terms.html')).toBe(PUBLIC_CANONICALS.terms); }); }); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 7775a76..dc90e47 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,11 +1,13 @@ import Link from 'next/link'; +import { PUBLIC_CANONICALS } from '@/lib/public-canonicals'; + export const metadata = { title: 'How to Organize and Semantically Search GitHub Stars | Starboard', description: 'Use GitHub lists first, then learn when hybrid lexical and semantic search, tags, collections, and maintenance signals help organize a large star library.', alternates: { - canonical: '/about', + canonical: PUBLIC_CANONICALS.about, }, }; diff --git a/src/app/catalog-updates/page.tsx b/src/app/catalog-updates/page.tsx index 296fcb5..f6be3d8 100644 --- a/src/app/catalog-updates/page.tsx +++ b/src/app/catalog-updates/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { Badge } from '@/components/ui/badge'; import { db } from '@/db'; import { getAvatarImageAttrs } from '@/lib/avatar'; +import { PUBLIC_CANONICALS } from '@/lib/public-canonicals'; import { CATALOG_UPDATES_DEFAULT_LIMIT, formatCatalogDate, @@ -19,7 +20,7 @@ export const metadata: Metadata = { description: 'Recently added popular repositories in the shared Starboard Discover corpus — catalogue ingestion history, not the product changelog.', alternates: { - canonical: '/catalog-updates', + canonical: PUBLIC_CANONICALS.catalogUpdates, }, }; diff --git a/src/app/page.tsx b/src/app/page.tsx index a19816e..db3e665 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,11 @@ import { redirect } from 'next/navigation'; import { SignInButton } from '@/components/sign-in-button'; import { auth } from '@/lib/auth'; +import { PUBLIC_CANONICALS } from '@/lib/public-canonicals'; + +export const metadata = { + alternates: { canonical: PUBLIC_CANONICALS.home }, +}; /** * Authenticated users go to the product. Anonymous production `/` is the Astro diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 77fc26a..0c692a2 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,63 +1,35 @@ import type { MetadataRoute } from 'next'; +import { PUBLIC_CANONICALS } from '@/lib/public-canonicals'; + export const dynamic = 'force-static'; const siteUrl = 'https://starboard.codevetter.com'; +// Per-route metadata. `path` is the exact self-canonical from PUBLIC_CANONICALS; +// the sitemap URL is built from it so the sitemap and canonical never drift. +const routeMeta: { + path: string; + changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency']; + priority: number; +}[] = [ + { path: PUBLIC_CANONICALS.home, changeFrequency: 'weekly', priority: 1 }, + { path: PUBLIC_CANONICALS.discover, changeFrequency: 'daily', priority: 0.95 }, + { path: PUBLIC_CANONICALS.projectPreview, changeFrequency: 'weekly', priority: 0.98 }, + { path: PUBLIC_CANONICALS.tools, changeFrequency: 'weekly', priority: 0.9 }, + { path: PUBLIC_CANONICALS.catalogUpdates, changeFrequency: 'daily', priority: 0.85 }, + { path: PUBLIC_CANONICALS.changelog, changeFrequency: 'monthly', priority: 0.65 }, + { path: PUBLIC_CANONICALS.about, changeFrequency: 'monthly', priority: 0.55 }, + { path: PUBLIC_CANONICALS.privacy, changeFrequency: 'yearly', priority: 0.3 }, + { path: PUBLIC_CANONICALS.terms, changeFrequency: 'yearly', priority: 0.3 }, +]; + export default function sitemap(): MetadataRoute.Sitemap { const now = new Date(); - - const marketing: MetadataRoute.Sitemap = [ - { url: siteUrl, lastModified: now, changeFrequency: 'weekly', priority: 1 }, - { - url: `${siteUrl}/discover`, - lastModified: now, - changeFrequency: 'daily', - priority: 0.95, - }, - { - url: `${siteUrl}/project-preview`, - lastModified: now, - changeFrequency: 'weekly', - priority: 0.98, - }, - { - url: `${siteUrl}/tools`, - lastModified: now, - changeFrequency: 'weekly', - priority: 0.9, - }, - { - url: `${siteUrl}/catalog-updates`, - lastModified: now, - changeFrequency: 'daily', - priority: 0.85, - }, - { - url: `${siteUrl}/changelog`, - lastModified: now, - changeFrequency: 'monthly', - priority: 0.65, - }, - { - url: `${siteUrl}/about`, - lastModified: now, - changeFrequency: 'monthly', - priority: 0.55, - }, - { - url: `${siteUrl}/privacy`, - lastModified: now, - changeFrequency: 'yearly', - priority: 0.3, - }, - { - url: `${siteUrl}/terms`, - lastModified: now, - changeFrequency: 'yearly', - priority: 0.3, - }, - ]; - - return marketing; + return routeMeta.map(({ path, changeFrequency, priority }) => ({ + url: `${siteUrl}${path}`, + lastModified: now, + changeFrequency, + priority, + })); } diff --git a/src/lib/public-canonicals.ts b/src/lib/public-canonicals.ts index ae6293a..4cfd80d 100644 --- a/src/lib/public-canonicals.ts +++ b/src/lib/public-canonicals.ts @@ -1,7 +1,29 @@ +/** + * Self-canonical paths for every public sitemap route. + * + * Single source of truth shared by `src/app/sitemap.ts`, the per-route + * `alternates.canonical` metadata, and the sitemap/canonical contract test + * (`src/__tests__/sitemap.test.ts`). Each value is an exact, extensionless + * self-canonical — no `.html`/`.md` suffixes, no cross-route redirects — so the + * canonical URL for a route is the route itself. + * + * Astro-served static pages (home, changelog) are overlaid into OpenNext + * assets as `index.html` / `changelog.html`; their canonical is produced at + * build time by `landing-astro/src/lib/canonical.ts` (`canonicalPath`), which + * strips the `.html` suffix and normalizes `/index` → `/`. The values here + * mirror that result so the contract test can assert parity without rendering. + */ export const PUBLIC_CANONICALS = { + home: '/', discover: '/discover', projectPreview: '/project-preview', tools: '/tools', + catalogUpdates: '/catalog-updates', + changelog: '/changelog', + about: '/about', privacy: '/privacy', terms: '/terms', } as const; + +/** Ordered list of every public sitemap route's self-canonical path. */ +export const PUBLIC_CANONICAL_PATHS = Object.values(PUBLIC_CANONICALS);