diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx index 8947258..0ab3b0f 100644 --- a/src/app/c/[slug]/page.tsx +++ b/src/app/c/[slug]/page.tsx @@ -28,6 +28,9 @@ export async function generateMetadata({ params }: CategoryPageProps): Promise { + it('exports correct metadata', () => { + expect(alt).toBe('Barazo - Community Forums on the AT Protocol') + expect(size).toEqual({ width: 1200, height: 630 }) + expect(contentType).toBe('image/png') + }) + + it('returns an ImageResponse', async () => { + const response = await OGImage() + expect(response).toBeDefined() + expect(response.headers.get('content-type')).toBe('image/png') + }) +}) diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..7d40aa6 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,80 @@ +/** + * Default OpenGraph image for social sharing. + * Generates a 1200x630 branded image with community name. + * @see specs/prd-web.md Section 5 (OpenGraph) + */ + +import { ImageResponse } from 'next/og' + +export const alt = 'Barazo - Community Forums on the AT Protocol' +export const size = { width: 1200, height: 630 } +export const contentType = 'image/png' + +export default async function OGImage(): Promise { + return new ImageResponse( +
+
+
+ B +
+ Barazo +
+
+ Community Forums on the AT Protocol +
+
+ Portable Identity + Data Ownership + Cross-Community +
+
, + { ...size } + ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 192554e..3a47b62 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,6 +18,9 @@ export const metadata: Metadata = { title: 'Barazo - Community Forums on the AT Protocol', description: 'Federated community forums with portable identity, user data ownership, and cross-community reputation.', + alternates: { + canonical: '/', + }, } export default async function HomePage() { diff --git a/src/app/robots.test.ts b/src/app/robots.test.ts new file mode 100644 index 0000000..018207b --- /dev/null +++ b/src/app/robots.test.ts @@ -0,0 +1,57 @@ +/** + * Tests for robots.txt configuration. + * @see specs/prd-web.md Section 5 (robots.txt) + */ + +import { describe, it, expect } from 'vitest' +import robots from './robots' + +describe('robots.txt', () => { + function getRules() { + const result = robots() + return Array.isArray(result.rules) ? result.rules : [result.rules] + } + + it('allows general crawlers on public pages', () => { + const rules = getRules() + expect(rules[0]).toMatchObject({ + userAgent: '*', + allow: '/', + }) + }) + + it('disallows admin, auth, API, and non-public pages', () => { + const rules = getRules() + expect(rules[0].disallow).toEqual( + expect.arrayContaining([ + '/admin/', + '/auth/', + '/api/', + '/search', + '/settings', + '/notifications', + ]) + ) + }) + + it('blocks SEO bots', () => { + const rules = getRules() + expect(rules[1].userAgent).toEqual( + expect.arrayContaining(['SemrushBot', 'AhrefsBot', 'MJ12bot']) + ) + expect(rules[1].disallow).toBe('/') + }) + + it('blocks AI crawlers', () => { + const rules = getRules() + expect(rules[2].userAgent).toEqual( + expect.arrayContaining(['GPTBot', 'ClaudeBot', 'CCBot', 'Google-Extended']) + ) + expect(rules[2].disallow).toBe('/') + }) + + it('includes sitemap directive', () => { + const result = robots() + expect(result.sitemap).toMatch(/\/sitemap\.xml$/) + }) +}) diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..47d4ce1 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,31 @@ +/** + * robots.txt configuration. + * Disallows admin, auth, API, and search pages. + * Blocks SEO bots and AI crawlers by default. + * @see specs/prd-web.md Section 5 (robots.txt) + */ + +import type { MetadataRoute } from 'next' + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: '*', + allow: '/', + disallow: ['/admin/', '/auth/', '/api/', '/search', '/settings', '/notifications'], + }, + { + userAgent: ['SemrushBot', 'AhrefsBot', 'MJ12bot'], + disallow: '/', + }, + { + userAgent: ['GPTBot', 'ClaudeBot', 'CCBot', 'Google-Extended'], + disallow: '/', + }, + ], + sitemap: `${SITE_URL}/sitemap.xml`, + } +} diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts new file mode 100644 index 0000000..dbdadc7 --- /dev/null +++ b/src/app/sitemap.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for sitemap generation. + * @see specs/prd-web.md Section 5 (Sitemaps) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the API client before importing sitemap +vi.mock('@/lib/api/client', () => ({ + getCategories: vi.fn(), + getTopics: vi.fn(), +})) + +import sitemap from './sitemap' +import { getCategories, getTopics } from '@/lib/api/client' + +const mockGetCategories = vi.mocked(getCategories) +const mockGetTopics = vi.mocked(getTopics) + +beforeEach(() => { + vi.clearAllMocks() + + mockGetCategories.mockResolvedValue({ + categories: [ + { + id: '1', + slug: 'general', + name: 'General', + description: null, + parentId: null, + sortOrder: 0, + communityDid: 'did:plc:test', + maturityRating: 'safe' as const, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-06-01T00:00:00Z', + children: [ + { + id: '2', + slug: 'introductions', + name: 'Introductions', + description: null, + parentId: '1', + sortOrder: 0, + communityDid: 'did:plc:test', + maturityRating: 'safe' as const, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-05-01T00:00:00Z', + children: [], + }, + ], + }, + ], + }) + + mockGetTopics.mockResolvedValue({ + topics: [ + { + uri: 'at://did:plc:test/forum.barazo.topic/abc123', + rkey: 'abc123', + authorDid: 'did:plc:author1', + title: 'Hello World', + content: 'First post', + contentFormat: null, + category: 'general', + tags: null, + communityDid: 'did:plc:test', + cid: 'bafyabc', + replyCount: 5, + reactionCount: 3, + lastActivityAt: '2025-06-15T12:00:00Z', + createdAt: '2025-06-01T00:00:00Z', + indexedAt: '2025-06-01T00:00:00Z', + }, + { + uri: 'at://did:plc:test/forum.barazo.topic/def456', + rkey: 'def456', + authorDid: 'did:plc:author2', + title: 'Second Topic', + content: 'Another post', + contentFormat: null, + category: 'general', + tags: null, + communityDid: 'did:plc:test', + cid: 'bafydef', + replyCount: 0, + reactionCount: 1, + lastActivityAt: '2025-06-10T08:00:00Z', + createdAt: '2025-06-10T00:00:00Z', + indexedAt: '2025-06-10T00:00:00Z', + }, + ], + cursor: null, + }) +}) + +describe('sitemap', () => { + it('includes the homepage', async () => { + const result = await sitemap() + const urls = result.map((entry) => entry.url) + expect(urls).toContain('https://barazo.forum') + }) + + it('includes category pages', async () => { + const result = await sitemap() + const urls = result.map((entry) => entry.url) + expect(urls).toContain('https://barazo.forum/c/general') + expect(urls).toContain('https://barazo.forum/c/introductions') + }) + + it('includes topic pages with slug and rkey', async () => { + const result = await sitemap() + const urls = result.map((entry) => entry.url) + expect(urls).toContain('https://barazo.forum/t/hello-world/abc123') + expect(urls).toContain('https://barazo.forum/t/second-topic/def456') + }) + + it('sets lastModified for topics', async () => { + const result = await sitemap() + const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) + expect(topicEntry?.lastModified).toBeDefined() + }) + + it('sets appropriate changeFrequency', async () => { + const result = await sitemap() + const homeEntry = result.find((entry) => entry.url === 'https://barazo.forum') + expect(homeEntry?.changeFrequency).toBe('hourly') + + const categoryEntry = result.find((entry) => entry.url.includes('/c/general')) + expect(categoryEntry?.changeFrequency).toBe('daily') + + const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) + expect(topicEntry?.changeFrequency).toBe('weekly') + }) + + it('sets priority values', async () => { + const result = await sitemap() + const homeEntry = result.find((entry) => entry.url === 'https://barazo.forum') + expect(homeEntry?.priority).toBe(1.0) + + const categoryEntry = result.find((entry) => entry.url.includes('/c/general')) + expect(categoryEntry?.priority).toBe(0.8) + + const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) + expect(topicEntry?.priority).toBe(0.6) + }) + + it('flattens nested category children', async () => { + const result = await sitemap() + const urls = result.map((entry) => entry.url) + // Both parent and child categories should be included + expect(urls).toContain('https://barazo.forum/c/general') + expect(urls).toContain('https://barazo.forum/c/introductions') + }) + + it('handles API errors gracefully', async () => { + mockGetCategories.mockRejectedValue(new Error('API down')) + mockGetTopics.mockRejectedValue(new Error('API down')) + + const result = await sitemap() + // Should still return at least the homepage + expect(result.length).toBeGreaterThanOrEqual(1) + expect(result[0].url).toBe('https://barazo.forum') + }) +}) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..6539edf --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,67 @@ +/** + * Dynamic sitemap generation. + * Includes homepage, categories (flattened tree), and topics. + * @see specs/prd-web.md Section 5 (Sitemaps) + */ + +import type { MetadataRoute } from 'next' +import { getCategories, getTopics } from '@/lib/api/client' +import { slugify } from '@/lib/format' +import type { CategoryTreeNode } from '@/lib/api/types' + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' + +function flattenCategories(nodes: CategoryTreeNode[]): CategoryTreeNode[] { + const result: CategoryTreeNode[] = [] + for (const node of nodes) { + result.push(node) + if (node.children.length > 0) { + result.push(...flattenCategories(node.children)) + } + } + return result +} + +export default async function sitemap(): Promise { + const entries: MetadataRoute.Sitemap = [ + { + url: SITE_URL, + lastModified: new Date(), + changeFrequency: 'hourly', + priority: 1.0, + }, + ] + + // Fetch categories and topics in parallel, gracefully handling errors + const [categoriesResult, topicsResult] = await Promise.all([ + getCategories().catch(() => null), + getTopics({ limit: 1000, sort: 'latest' }).catch(() => null), + ]) + + // Add category pages + if (categoriesResult) { + const allCategories = flattenCategories(categoriesResult.categories) + for (const category of allCategories) { + entries.push({ + url: `${SITE_URL}/c/${category.slug}`, + lastModified: new Date(category.updatedAt), + changeFrequency: 'daily', + priority: 0.8, + }) + } + } + + // Add topic pages + if (topicsResult) { + for (const topic of topicsResult.topics) { + entries.push({ + url: `${SITE_URL}/t/${slugify(topic.title)}/${topic.rkey}`, + lastModified: new Date(topic.lastActivityAt), + changeFrequency: 'weekly', + priority: 0.6, + }) + } + } + + return entries +} diff --git a/src/app/t/[slug]/[rkey]/page.tsx b/src/app/t/[slug]/[rkey]/page.tsx index 5a377bc..e41da70 100644 --- a/src/app/t/[slug]/[rkey]/page.tsx +++ b/src/app/t/[slug]/[rkey]/page.tsx @@ -8,6 +8,7 @@ import type { Metadata } from 'next' import { notFound } from 'next/navigation' import { getTopicByRkey, getCategories, getReplies, ApiError } from '@/lib/api/client' +import { slugify } from '@/lib/format' import { ForumLayout } from '@/components/layout/forum-layout' import { CategoryNav } from '@/components/category-nav' import { Breadcrumbs } from '@/components/breadcrumbs' @@ -31,6 +32,9 @@ export async function generateMetadata({ params }: TopicPageProps): Promise