diff --git a/website/app/[category]/[slug]/page.tsx b/website/app/[category]/[slug]/page.tsx index 702da51..c7419dd 100644 --- a/website/app/[category]/[slug]/page.tsx +++ b/website/app/[category]/[slug]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import ReactMarkdown from "react-markdown"; @@ -5,7 +6,7 @@ import remarkGfm from "remark-gfm"; import { getPatternBySlug, getPatternSlugs, titleToSlug } from "@/lib/markdown"; import { getCategoryConfig, isValidCategory } from "@/app/lib/category-config"; import { basePath } from "@/lib/config"; -import { PatternCategory } from "@/lib/types"; +import { PatternCategory, PatternContent } from "@/lib/types"; import Authors from "@/app/components/Authors"; import RelatedLinks from "@/app/components/RelatedLinks"; import VideoThumbnail from "@/app/components/VideoThumbnail"; @@ -48,52 +49,66 @@ export async function generateStaticParams() { return params; } -export default async function PatternPage({ params }: PatternPageProps) { +export async function generateMetadata({ params }: PatternPageProps): Promise { const { category, slug } = await params; if (!isValidCategory(category)) { - notFound(); + return {}; } - const config = getCategoryConfig(category as PatternCategory); + const resolved = resolvePattern(category as PatternCategory, slug); + return resolved ? { title: resolved.pattern.title } : {}; +} - // First try to get the pattern by the given slug (might be canonical or alternative) - let pattern = null; +interface ResolvedPattern { + pattern: PatternContent; + canonicalSlug: string; + isAlternativeTitle: boolean; +} + +function resolvePattern(category: PatternCategory, slug: string): ResolvedPattern | null { + const pattern = findPatternBySlug(category, slug); + if (pattern) { + return { pattern, canonicalSlug: slug, isAlternativeTitle: false }; + } + return findPatternByAlternativeTitleSlug(category, slug); +} + +function findPatternBySlug(category: PatternCategory, slug: string): PatternContent | null { try { - pattern = getPatternBySlug(category as PatternCategory, slug); + return getPatternBySlug(category, slug); } catch { - // Slug might be an alternative title slug, not a canonical file slug - // We'll search for it below + return null; } +} - // If not found directly, search all patterns to find one with a matching alternative title - let canonicalSlug = slug; - let isAlternativeTitle = false; - - if (!pattern) { - const allSlugs = getPatternSlugs(category as PatternCategory); - for (const candidateSlug of allSlugs) { - const candidatePattern = getPatternBySlug(category as PatternCategory, candidateSlug); - if (candidatePattern?.alternativeTitles) { - // Check if any alternative title converts to the requested slug - for (const altTitle of candidatePattern.alternativeTitles) { - if (titleToSlug(altTitle) === slug) { - pattern = candidatePattern; - canonicalSlug = candidateSlug; - isAlternativeTitle = true; - break; - } - } - if (isAlternativeTitle) break; - } +function findPatternByAlternativeTitleSlug(category: PatternCategory, slug: string): ResolvedPattern | null { + for (const candidateSlug of getPatternSlugs(category)) { + const candidate = getPatternBySlug(category, candidateSlug); + if (candidate?.alternativeTitles?.some((altTitle) => titleToSlug(altTitle) === slug)) { + return { pattern: candidate, canonicalSlug: candidateSlug, isAlternativeTitle: true }; } } + return null; +} + +export default async function PatternPage({ params }: PatternPageProps) { + const { category, slug } = await params; + + if (!isValidCategory(category)) { + notFound(); + } + + const config = getCategoryConfig(category as PatternCategory); + + const resolved = resolvePattern(category as PatternCategory, slug); - if (!pattern) { + if (!resolved) { notFound(); } - // If visiting an alternative title slug, render redirect page + const { pattern, canonicalSlug, isAlternativeTitle } = resolved; + if (isAlternativeTitle) { const canonicalPath = `/${category}/${canonicalSlug}/`; const canonicalUrl = `${basePath}${canonicalPath}`; @@ -102,7 +117,6 @@ export default async function PatternPage({ params }: PatternPageProps) { - Redirecting to {pattern.title}
diff --git a/website/app/[category]/page.tsx b/website/app/[category]/page.tsx index 532f101..c5ea7fa 100644 --- a/website/app/[category]/page.tsx +++ b/website/app/[category]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getAllPatterns } from "@/lib/markdown"; @@ -20,6 +21,16 @@ export async function generateStaticParams() { ]; } +export async function generateMetadata({ params }: CategoryPageProps): Promise { + const { category } = await params; + + if (!isValidCategory(category)) { + return {}; + } + + return { title: getCategoryConfig(category as PatternCategory).labelPlural }; +} + export default async function CategoryPage({ params }: CategoryPageProps) { const { category } = await params; diff --git a/website/app/contributors/[authorId]/page.tsx b/website/app/contributors/[authorId]/page.tsx index 09ed02a..7dca227 100644 --- a/website/app/contributors/[authorId]/page.tsx +++ b/website/app/contributors/[authorId]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { getContributorDetailById, getContributorStats } from "@/lib/contributors"; @@ -16,6 +17,13 @@ interface ContributorDetailPageProps { }>; } +export async function generateMetadata({ params }: ContributorDetailPageProps): Promise { + const { authorId } = await params; + const contributor = getContributorDetailById(authorId); + + return contributor ? { title: contributor.name } : {}; +} + export default async function ContributorDetailPage({ params }: ContributorDetailPageProps) { const { authorId } = await params; const contributor = getContributorDetailById(authorId); diff --git a/website/app/contributors/page.tsx b/website/app/contributors/page.tsx index d10a791..4a31edb 100644 --- a/website/app/contributors/page.tsx +++ b/website/app/contributors/page.tsx @@ -1,16 +1,23 @@ +import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { getContributorStats } from "@/lib/contributors"; import { siteConfig } from "@/config/site"; import styles from "./page.module.css"; +const pageTitle = "Contributors"; + +export const metadata: Metadata = { + title: pageTitle, +}; + export default function ContributorsPage() { const contributors = getContributorStats(); return (
-

Contributors

+

{pageTitle}

Thank you to all the contributors who have documented patterns, anti-patterns, and obstacles.

diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 5cfa785..a664b0e 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -18,7 +18,10 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: siteConfig.name, + title: { + default: siteConfig.name, + template: `%s | ${siteConfig.name}`, + }, description: siteConfig.description, }; diff --git a/website/app/pattern-catalog/page.tsx b/website/app/pattern-catalog/page.tsx index 44d25b1..b701032 100644 --- a/website/app/pattern-catalog/page.tsx +++ b/website/app/pattern-catalog/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import styles from "./page.module.css"; import { PATTERN_CATALOG_GROUPS } from "./constants"; import { getAllPatterns } from "@/lib/markdown"; @@ -6,6 +7,12 @@ import CatalogView from "./CatalogView"; import { CatalogGroupData, CatalogPreviewItem } from "./types"; import { getCategoryConfig } from "@/app/lib/category-config"; +const catalogTitle = "Complete Catalog"; + +export const metadata: Metadata = { + title: catalogTitle, +}; + function extractSummary(markdown: string): string | undefined { const lines = markdown.split("\n"); const summaryLines: string[] = []; @@ -110,7 +117,7 @@ export default function PatternCatalogPage() { return (
- +
); } diff --git a/website/app/talk/page.tsx b/website/app/talk/page.tsx index 82dc903..577bc2b 100644 --- a/website/app/talk/page.tsx +++ b/website/app/talk/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import PatternMap from "./PatternMap"; import MapTabs from "./MapTabs"; import { getAllPatterns } from "@/lib/markdown"; @@ -11,6 +12,12 @@ type PatternData = PatternContent & { category: string; }; +const currentMapTitle = "Patterns for Coding with AI"; + +export const metadata: Metadata = { + title: currentMapTitle, +}; + type MapIndex = Record; function buildDataByNumber(index: MapIndex, allPatterns: PatternData[]): Record { @@ -73,7 +80,7 @@ export default function TalkPage() { { id: 'v3', label: 'v3', - title: 'Patterns for Coding with AI', + title: currentMapTitle, walkthroughUrl: 'https://www.youtube.com/watch?v=_LSK2bVf0Lc&t=5301s', walkthroughLabel: 'watch an older version', content: ( diff --git a/website/tests/e2e/smoke.spec.ts b/website/tests/e2e/smoke.spec.ts index c20d273..4051b26 100644 --- a/website/tests/e2e/smoke.spec.ts +++ b/website/tests/e2e/smoke.spec.ts @@ -31,6 +31,13 @@ test.describe('Smoke Tests - Critical User Journeys', () => { await expect(page.getByRole('article')).toBeVisible(); }); + test('pattern page title names the pattern', async ({ page }) => { + await page.goto('/anti-patterns/answer-injection/'); + + const heading = await page.getByRole('heading', { level: 1 }).textContent(); + await expect(page).toHaveTitle(`${heading} | Augmented Coding Patterns`); + }); + test('use back button to return to patterns list', async ({ page }) => { await page.goto('/patterns/'); diff --git a/website/tests/unit/components/pages.test.tsx b/website/tests/unit/components/pages.test.tsx index 9bbc4d9..a9a67bd 100644 --- a/website/tests/unit/components/pages.test.tsx +++ b/website/tests/unit/components/pages.test.tsx @@ -1,8 +1,14 @@ import { render, screen } from '@testing-library/react' +import { metadata as siteMetadata } from '@/app/layout' import Home from '@/app/page' -import CategoryPage from '@/app/[category]/page' -import PatternPage from '@/app/[category]/[slug]/page' +import CategoryPage, { generateMetadata as generateCategoryMetadata } from '@/app/[category]/page' +import PatternPage, { generateMetadata as generatePatternMetadata } from '@/app/[category]/[slug]/page' +import { metadata as contributorsMetadata } from '@/app/contributors/page' +import { generateMetadata as generateContributorMetadata } from '@/app/contributors/[authorId]/page' +import { metadata as patternCatalogMetadata } from '@/app/pattern-catalog/page' +import { metadata as talkMetadata } from '@/app/talk/page' import { getPatternSlugs, getAllPatterns, getPatternBySlug } from '@/lib/markdown' +import { getContributorDetailById, ContributorDetail } from '@/lib/contributors' jest.mock('next/navigation', () => ({ useRouter: () => ({ @@ -17,11 +23,104 @@ jest.mock('@/lib/markdown', () => ({ getPatternSlugs: jest.fn(), getAllPatterns: jest.fn(() => []), getPatternBySlug: jest.fn(), + titleToSlug: jest.requireActual('@/lib/markdown').titleToSlug, +})) + +jest.mock('@/lib/contributors', () => ({ + getContributorStats: jest.fn(() => []), + getContributorDetailById: jest.fn(), })) const mockGetPatternSlugs = getPatternSlugs as jest.MockedFunction const mockGetAllPatterns = getAllPatterns as jest.MockedFunction const mockGetPatternBySlug = getPatternBySlug as jest.MockedFunction +const mockGetContributorDetailById = getContributorDetailById as jest.MockedFunction + +const activePartnerWithAlternativeTitle = { + title: 'Active Partner', + slug: 'active-partner', + category: 'patterns' as const, + content: 'Content', + rawContent: '# Active Partner\n\nContent', + alternativeTitles: ['Push Back'] +} + +const servePatternOnlyByAlternativeTitle = () => { + mockGetPatternSlugs.mockReturnValue(['active-partner']) + mockGetPatternBySlug.mockImplementation((_category, slug) => { + if (slug !== 'active-partner') throw new Error('not found') + return activePartnerWithAlternativeTitle + }) +} + +describe('Page titles', () => { + afterEach(() => { + mockGetPatternSlugs.mockReset() + mockGetPatternBySlug.mockReset() + mockGetContributorDetailById.mockReset() + }) + + it('suffixes page titles with the site name and keeps the bare site name where a page sets none', () => { + expect(siteMetadata.title).toEqual({ + default: 'Augmented Coding Patterns', + template: '%s | Augmented Coding Patterns', + }) + }) + + it('titles a pattern page with the pattern title', async () => { + mockGetPatternBySlug.mockReturnValue({ + title: 'Answer Injection', + slug: 'answer-injection', + category: 'anti-patterns', + content: 'Content', + rawContent: '# Answer Injection\n\nContent' + }) + const params = Promise.resolve({ category: 'anti-patterns', slug: 'answer-injection' }) + + expect((await generatePatternMetadata({ params })).title).toBe('Answer Injection') + }) + + it('titles a pattern reached by an alternative title with the canonical pattern title', async () => { + servePatternOnlyByAlternativeTitle() + const params = Promise.resolve({ category: 'patterns', slug: 'push-back' }) + + expect((await generatePatternMetadata({ params })).title).toBe('Active Partner') + }) + + it('leaves the alternative-title redirect page without a title of its own', async () => { + servePatternOnlyByAlternativeTitle() + const params = Promise.resolve({ category: 'patterns', slug: 'push-back' }) + + render(await PatternPage({ params })) + + expect(document.querySelector('title')).toBeNull() + }) + + it('titles a category page with the plural category label', async () => { + const params = Promise.resolve({ category: 'anti-patterns' }) + + expect((await generateCategoryMetadata({ params })).title).toBe('Anti-Patterns') + }) + + it('titles a contributor page with the contributor name', async () => { + mockGetContributorDetailById.mockReturnValue({ name: 'Ivett Ördög' } as ContributorDetail) + const params = Promise.resolve({ authorId: 'ivett_ordog' }) + + expect((await generateContributorMetadata({ params })).title).toBe('Ivett Ördög') + }) + + it('titles the contributors page', () => { + expect(contributorsMetadata.title).toBe('Contributors') + }) + + it('titles the pattern catalog page', () => { + expect(patternCatalogMetadata.title).toBe('Complete Catalog') + }) + + it('titles the talk page with its default map title', () => { + expect(talkMetadata.title).toBe('Patterns for Coding with AI') + }) +}) describe('Home Page', () => { beforeEach(() => {