Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(seo): add sitemaps, robots.txt, OG image, and canonical URLs (M12) (#13)

SEO finalization milestone: dynamic sitemap with categories and topics,
robots.txt blocking admin/auth/API pages plus SEO and AI crawlers,
branded OG image generation, and self-referencing canonical URLs on
homepage, category, and topic pages.

authored by

Guido X Jansen and committed by
GitHub
34095950 2c159d32

+430
+3
src/app/c/[slug]/page.tsx
··· 28 28 return { 29 29 title: category.name, 30 30 description: category.description ?? `Topics in ${category.name}`, 31 + alternates: { 32 + canonical: `/c/${slug}`, 33 + }, 31 34 openGraph: { 32 35 title: category.name, 33 36 description: category.description ?? `Topics in ${category.name}`,
+21
src/app/opengraph-image.test.tsx
··· 1 + /** 2 + * Tests for default OpenGraph image generation. 3 + * @see specs/prd-web.md Section 5 (OG image) 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest' 7 + import OGImage, { alt, size, contentType } from './opengraph-image' 8 + 9 + describe('opengraph-image', () => { 10 + it('exports correct metadata', () => { 11 + expect(alt).toBe('Barazo - Community Forums on the AT Protocol') 12 + expect(size).toEqual({ width: 1200, height: 630 }) 13 + expect(contentType).toBe('image/png') 14 + }) 15 + 16 + it('returns an ImageResponse', async () => { 17 + const response = await OGImage() 18 + expect(response).toBeDefined() 19 + expect(response.headers.get('content-type')).toBe('image/png') 20 + }) 21 + })
+80
src/app/opengraph-image.tsx
··· 1 + /** 2 + * Default OpenGraph image for social sharing. 3 + * Generates a 1200x630 branded image with community name. 4 + * @see specs/prd-web.md Section 5 (OpenGraph) 5 + */ 6 + 7 + import { ImageResponse } from 'next/og' 8 + 9 + export const alt = 'Barazo - Community Forums on the AT Protocol' 10 + export const size = { width: 1200, height: 630 } 11 + export const contentType = 'image/png' 12 + 13 + export default async function OGImage(): Promise<ImageResponse> { 14 + return new ImageResponse( 15 + <div 16 + style={{ 17 + display: 'flex', 18 + flexDirection: 'column', 19 + alignItems: 'center', 20 + justifyContent: 'center', 21 + width: '100%', 22 + height: '100%', 23 + backgroundColor: '#1c1917', 24 + color: '#e8e4e0', 25 + fontFamily: 'sans-serif', 26 + }} 27 + > 28 + <div 29 + style={{ 30 + display: 'flex', 31 + alignItems: 'center', 32 + gap: '16px', 33 + marginBottom: '24px', 34 + }} 35 + > 36 + <div 37 + style={{ 38 + display: 'flex', 39 + alignItems: 'center', 40 + justifyContent: 'center', 41 + width: '64px', 42 + height: '64px', 43 + borderRadius: '50%', 44 + backgroundColor: '#31748f', 45 + fontSize: '36px', 46 + fontWeight: 700, 47 + color: '#e8e4e0', 48 + }} 49 + > 50 + B 51 + </div> 52 + <span style={{ fontSize: '56px', fontWeight: 700 }}>Barazo</span> 53 + </div> 54 + <div 55 + style={{ 56 + fontSize: '24px', 57 + color: '#a0a0a0', 58 + maxWidth: '600px', 59 + textAlign: 'center', 60 + }} 61 + > 62 + Community Forums on the AT Protocol 63 + </div> 64 + <div 65 + style={{ 66 + display: 'flex', 67 + gap: '32px', 68 + marginTop: '40px', 69 + fontSize: '18px', 70 + color: '#31748f', 71 + }} 72 + > 73 + <span>Portable Identity</span> 74 + <span>Data Ownership</span> 75 + <span>Cross-Community</span> 76 + </div> 77 + </div>, 78 + { ...size } 79 + ) 80 + }
+3
src/app/page.tsx
··· 18 18 title: 'Barazo - Community Forums on the AT Protocol', 19 19 description: 20 20 'Federated community forums with portable identity, user data ownership, and cross-community reputation.', 21 + alternates: { 22 + canonical: '/', 23 + }, 21 24 } 22 25 23 26 export default async function HomePage() {
+57
src/app/robots.test.ts
··· 1 + /** 2 + * Tests for robots.txt configuration. 3 + * @see specs/prd-web.md Section 5 (robots.txt) 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest' 7 + import robots from './robots' 8 + 9 + describe('robots.txt', () => { 10 + function getRules() { 11 + const result = robots() 12 + return Array.isArray(result.rules) ? result.rules : [result.rules] 13 + } 14 + 15 + it('allows general crawlers on public pages', () => { 16 + const rules = getRules() 17 + expect(rules[0]).toMatchObject({ 18 + userAgent: '*', 19 + allow: '/', 20 + }) 21 + }) 22 + 23 + it('disallows admin, auth, API, and non-public pages', () => { 24 + const rules = getRules() 25 + expect(rules[0].disallow).toEqual( 26 + expect.arrayContaining([ 27 + '/admin/', 28 + '/auth/', 29 + '/api/', 30 + '/search', 31 + '/settings', 32 + '/notifications', 33 + ]) 34 + ) 35 + }) 36 + 37 + it('blocks SEO bots', () => { 38 + const rules = getRules() 39 + expect(rules[1].userAgent).toEqual( 40 + expect.arrayContaining(['SemrushBot', 'AhrefsBot', 'MJ12bot']) 41 + ) 42 + expect(rules[1].disallow).toBe('/') 43 + }) 44 + 45 + it('blocks AI crawlers', () => { 46 + const rules = getRules() 47 + expect(rules[2].userAgent).toEqual( 48 + expect.arrayContaining(['GPTBot', 'ClaudeBot', 'CCBot', 'Google-Extended']) 49 + ) 50 + expect(rules[2].disallow).toBe('/') 51 + }) 52 + 53 + it('includes sitemap directive', () => { 54 + const result = robots() 55 + expect(result.sitemap).toMatch(/\/sitemap\.xml$/) 56 + }) 57 + })
+31
src/app/robots.ts
··· 1 + /** 2 + * robots.txt configuration. 3 + * Disallows admin, auth, API, and search pages. 4 + * Blocks SEO bots and AI crawlers by default. 5 + * @see specs/prd-web.md Section 5 (robots.txt) 6 + */ 7 + 8 + import type { MetadataRoute } from 'next' 9 + 10 + const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' 11 + 12 + export default function robots(): MetadataRoute.Robots { 13 + return { 14 + rules: [ 15 + { 16 + userAgent: '*', 17 + allow: '/', 18 + disallow: ['/admin/', '/auth/', '/api/', '/search', '/settings', '/notifications'], 19 + }, 20 + { 21 + userAgent: ['SemrushBot', 'AhrefsBot', 'MJ12bot'], 22 + disallow: '/', 23 + }, 24 + { 25 + userAgent: ['GPTBot', 'ClaudeBot', 'CCBot', 'Google-Extended'], 26 + disallow: '/', 27 + }, 28 + ], 29 + sitemap: `${SITE_URL}/sitemap.xml`, 30 + } 31 + }
+164
src/app/sitemap.test.ts
··· 1 + /** 2 + * Tests for sitemap generation. 3 + * @see specs/prd-web.md Section 5 (Sitemaps) 4 + */ 5 + 6 + import { describe, it, expect, vi, beforeEach } from 'vitest' 7 + 8 + // Mock the API client before importing sitemap 9 + vi.mock('@/lib/api/client', () => ({ 10 + getCategories: vi.fn(), 11 + getTopics: vi.fn(), 12 + })) 13 + 14 + import sitemap from './sitemap' 15 + import { getCategories, getTopics } from '@/lib/api/client' 16 + 17 + const mockGetCategories = vi.mocked(getCategories) 18 + const mockGetTopics = vi.mocked(getTopics) 19 + 20 + beforeEach(() => { 21 + vi.clearAllMocks() 22 + 23 + mockGetCategories.mockResolvedValue({ 24 + categories: [ 25 + { 26 + id: '1', 27 + slug: 'general', 28 + name: 'General', 29 + description: null, 30 + parentId: null, 31 + sortOrder: 0, 32 + communityDid: 'did:plc:test', 33 + maturityRating: 'safe' as const, 34 + createdAt: '2025-01-01T00:00:00Z', 35 + updatedAt: '2025-06-01T00:00:00Z', 36 + children: [ 37 + { 38 + id: '2', 39 + slug: 'introductions', 40 + name: 'Introductions', 41 + description: null, 42 + parentId: '1', 43 + sortOrder: 0, 44 + communityDid: 'did:plc:test', 45 + maturityRating: 'safe' as const, 46 + createdAt: '2025-01-01T00:00:00Z', 47 + updatedAt: '2025-05-01T00:00:00Z', 48 + children: [], 49 + }, 50 + ], 51 + }, 52 + ], 53 + }) 54 + 55 + mockGetTopics.mockResolvedValue({ 56 + topics: [ 57 + { 58 + uri: 'at://did:plc:test/forum.barazo.topic/abc123', 59 + rkey: 'abc123', 60 + authorDid: 'did:plc:author1', 61 + title: 'Hello World', 62 + content: 'First post', 63 + contentFormat: null, 64 + category: 'general', 65 + tags: null, 66 + communityDid: 'did:plc:test', 67 + cid: 'bafyabc', 68 + replyCount: 5, 69 + reactionCount: 3, 70 + lastActivityAt: '2025-06-15T12:00:00Z', 71 + createdAt: '2025-06-01T00:00:00Z', 72 + indexedAt: '2025-06-01T00:00:00Z', 73 + }, 74 + { 75 + uri: 'at://did:plc:test/forum.barazo.topic/def456', 76 + rkey: 'def456', 77 + authorDid: 'did:plc:author2', 78 + title: 'Second Topic', 79 + content: 'Another post', 80 + contentFormat: null, 81 + category: 'general', 82 + tags: null, 83 + communityDid: 'did:plc:test', 84 + cid: 'bafydef', 85 + replyCount: 0, 86 + reactionCount: 1, 87 + lastActivityAt: '2025-06-10T08:00:00Z', 88 + createdAt: '2025-06-10T00:00:00Z', 89 + indexedAt: '2025-06-10T00:00:00Z', 90 + }, 91 + ], 92 + cursor: null, 93 + }) 94 + }) 95 + 96 + describe('sitemap', () => { 97 + it('includes the homepage', async () => { 98 + const result = await sitemap() 99 + const urls = result.map((entry) => entry.url) 100 + expect(urls).toContain('https://barazo.forum') 101 + }) 102 + 103 + it('includes category pages', async () => { 104 + const result = await sitemap() 105 + const urls = result.map((entry) => entry.url) 106 + expect(urls).toContain('https://barazo.forum/c/general') 107 + expect(urls).toContain('https://barazo.forum/c/introductions') 108 + }) 109 + 110 + it('includes topic pages with slug and rkey', async () => { 111 + const result = await sitemap() 112 + const urls = result.map((entry) => entry.url) 113 + expect(urls).toContain('https://barazo.forum/t/hello-world/abc123') 114 + expect(urls).toContain('https://barazo.forum/t/second-topic/def456') 115 + }) 116 + 117 + it('sets lastModified for topics', async () => { 118 + const result = await sitemap() 119 + const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) 120 + expect(topicEntry?.lastModified).toBeDefined() 121 + }) 122 + 123 + it('sets appropriate changeFrequency', async () => { 124 + const result = await sitemap() 125 + const homeEntry = result.find((entry) => entry.url === 'https://barazo.forum') 126 + expect(homeEntry?.changeFrequency).toBe('hourly') 127 + 128 + const categoryEntry = result.find((entry) => entry.url.includes('/c/general')) 129 + expect(categoryEntry?.changeFrequency).toBe('daily') 130 + 131 + const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) 132 + expect(topicEntry?.changeFrequency).toBe('weekly') 133 + }) 134 + 135 + it('sets priority values', async () => { 136 + const result = await sitemap() 137 + const homeEntry = result.find((entry) => entry.url === 'https://barazo.forum') 138 + expect(homeEntry?.priority).toBe(1.0) 139 + 140 + const categoryEntry = result.find((entry) => entry.url.includes('/c/general')) 141 + expect(categoryEntry?.priority).toBe(0.8) 142 + 143 + const topicEntry = result.find((entry) => entry.url.includes('/t/hello-world/abc123')) 144 + expect(topicEntry?.priority).toBe(0.6) 145 + }) 146 + 147 + it('flattens nested category children', async () => { 148 + const result = await sitemap() 149 + const urls = result.map((entry) => entry.url) 150 + // Both parent and child categories should be included 151 + expect(urls).toContain('https://barazo.forum/c/general') 152 + expect(urls).toContain('https://barazo.forum/c/introductions') 153 + }) 154 + 155 + it('handles API errors gracefully', async () => { 156 + mockGetCategories.mockRejectedValue(new Error('API down')) 157 + mockGetTopics.mockRejectedValue(new Error('API down')) 158 + 159 + const result = await sitemap() 160 + // Should still return at least the homepage 161 + expect(result.length).toBeGreaterThanOrEqual(1) 162 + expect(result[0].url).toBe('https://barazo.forum') 163 + }) 164 + })
+67
src/app/sitemap.ts
··· 1 + /** 2 + * Dynamic sitemap generation. 3 + * Includes homepage, categories (flattened tree), and topics. 4 + * @see specs/prd-web.md Section 5 (Sitemaps) 5 + */ 6 + 7 + import type { MetadataRoute } from 'next' 8 + import { getCategories, getTopics } from '@/lib/api/client' 9 + import { slugify } from '@/lib/format' 10 + import type { CategoryTreeNode } from '@/lib/api/types' 11 + 12 + const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' 13 + 14 + function flattenCategories(nodes: CategoryTreeNode[]): CategoryTreeNode[] { 15 + const result: CategoryTreeNode[] = [] 16 + for (const node of nodes) { 17 + result.push(node) 18 + if (node.children.length > 0) { 19 + result.push(...flattenCategories(node.children)) 20 + } 21 + } 22 + return result 23 + } 24 + 25 + export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 26 + const entries: MetadataRoute.Sitemap = [ 27 + { 28 + url: SITE_URL, 29 + lastModified: new Date(), 30 + changeFrequency: 'hourly', 31 + priority: 1.0, 32 + }, 33 + ] 34 + 35 + // Fetch categories and topics in parallel, gracefully handling errors 36 + const [categoriesResult, topicsResult] = await Promise.all([ 37 + getCategories().catch(() => null), 38 + getTopics({ limit: 1000, sort: 'latest' }).catch(() => null), 39 + ]) 40 + 41 + // Add category pages 42 + if (categoriesResult) { 43 + const allCategories = flattenCategories(categoriesResult.categories) 44 + for (const category of allCategories) { 45 + entries.push({ 46 + url: `${SITE_URL}/c/${category.slug}`, 47 + lastModified: new Date(category.updatedAt), 48 + changeFrequency: 'daily', 49 + priority: 0.8, 50 + }) 51 + } 52 + } 53 + 54 + // Add topic pages 55 + if (topicsResult) { 56 + for (const topic of topicsResult.topics) { 57 + entries.push({ 58 + url: `${SITE_URL}/t/${slugify(topic.title)}/${topic.rkey}`, 59 + lastModified: new Date(topic.lastActivityAt), 60 + changeFrequency: 'weekly', 61 + priority: 0.6, 62 + }) 63 + } 64 + } 65 + 66 + return entries 67 + }
+4
src/app/t/[slug]/[rkey]/page.tsx
··· 8 8 import type { Metadata } from 'next' 9 9 import { notFound } from 'next/navigation' 10 10 import { getTopicByRkey, getCategories, getReplies, ApiError } from '@/lib/api/client' 11 + import { slugify } from '@/lib/format' 11 12 import { ForumLayout } from '@/components/layout/forum-layout' 12 13 import { CategoryNav } from '@/components/category-nav' 13 14 import { Breadcrumbs } from '@/components/breadcrumbs' ··· 31 32 return { 32 33 title: topic.title, 33 34 description, 35 + alternates: { 36 + canonical: `/t/${slugify(topic.title)}/${rkey}`, 37 + }, 34 38 openGraph: { 35 39 title: topic.title, 36 40 description,