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
78 changes: 46 additions & 32 deletions website/app/[category]/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
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";
Expand Down Expand Up @@ -48,52 +49,66 @@ export async function generateStaticParams() {
return params;
}

export default async function PatternPage({ params }: PatternPageProps) {
export async function generateMetadata({ params }: PatternPageProps): Promise<Metadata> {
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}`;
Expand All @@ -102,7 +117,6 @@ export default async function PatternPage({ params }: PatternPageProps) {
<head>
<meta httpEquiv="refresh" content={`0; url=${canonicalUrl}`} />
<link rel="canonical" href={canonicalUrl} />
<title>Redirecting to {pattern.title}</title>
</head>
<body>
<div className={styles.container}>
Expand Down
11 changes: 11 additions & 0 deletions website/app/[category]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllPatterns } from "@/lib/markdown";
Expand All @@ -20,6 +21,16 @@ export async function generateStaticParams() {
];
}

export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
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;

Expand Down
8 changes: 8 additions & 0 deletions website/app/contributors/[authorId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +17,13 @@ interface ContributorDetailPageProps {
}>;
}

export async function generateMetadata({ params }: ContributorDetailPageProps): Promise<Metadata> {
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);
Expand Down
9 changes: 8 additions & 1 deletion website/app/contributors/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.container}>
<header className={styles.header}>
<h1 className={styles.title}>Contributors</h1>
<h1 className={styles.title}>{pageTitle}</h1>
<p className={styles.description}>
Thank you to all the contributors who have documented patterns, anti-patterns, and obstacles.
</p>
Expand Down
5 changes: 4 additions & 1 deletion website/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
9 changes: 8 additions & 1 deletion website/app/pattern-catalog/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -110,7 +117,7 @@ export default function PatternCatalogPage() {

return (
<div className={styles.container}>
<CatalogView title="Complete Catalog" groups={catalogGroups} />
<CatalogView title={catalogTitle} groups={catalogGroups} />
</div>
);
}
9 changes: 8 additions & 1 deletion website/app/talk/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import PatternMap from "./PatternMap";
import MapTabs from "./MapTabs";
import { getAllPatterns } from "@/lib/markdown";
Expand All @@ -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<string, { name: string; category: string; slug: string }>;

function buildDataByNumber(index: MapIndex, allPatterns: PatternData[]): Record<string, PatternData> {
Expand Down Expand Up @@ -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: (
Expand Down
7 changes: 7 additions & 0 deletions website/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/');

Expand Down
103 changes: 101 additions & 2 deletions website/tests/unit/components/pages.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => ({
Expand All @@ -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<typeof getPatternSlugs>
const mockGetAllPatterns = getAllPatterns as jest.MockedFunction<typeof getAllPatterns>
const mockGetPatternBySlug = getPatternBySlug as jest.MockedFunction<typeof getPatternBySlug>
const mockGetContributorDetailById = getContributorDetailById as jest.MockedFunction<typeof getContributorDetailById>

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(() => {
Expand Down
Loading