Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions src/__tests__/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -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`,
Expand All @@ -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);
});
});
4 changes: 3 additions & 1 deletion src/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
},
};

Expand Down
3 changes: 2 additions & 1 deletion src/app/catalog-updates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
};

Expand Down
5 changes: 5 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 26 additions & 54 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
}
22 changes: 22 additions & 0 deletions src/lib/public-canonicals.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading