Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(seo): P2.4 maturity-aware SEO metadata and sitemap filtering (#24)

* feat(seo): maturity-aware metadata, sitemap filtering, and JSON-LD gating

* fix(ci): format dependabot.yml with prettier

authored by

Guido X Jansen and committed by
GitHub
95a60434 aa0d4ea2

+369 -33
+10 -10
.github/dependabot.yml
··· 1 1 version: 2 2 2 updates: 3 - - package-ecosystem: "npm" 4 - directory: "/" 3 + - package-ecosystem: 'npm' 4 + directory: '/' 5 5 schedule: 6 - interval: "weekly" 6 + interval: 'weekly' 7 7 open-pull-requests-limit: 10 8 8 groups: 9 9 dependencies: 10 10 patterns: 11 - - "*" 11 + - '*' 12 12 update-types: 13 - - "minor" 14 - - "patch" 13 + - 'minor' 14 + - 'patch' 15 15 ignore: 16 - - dependency-name: "*" 17 - update-types: ["version-update:semver-major"] 16 + - dependency-name: '*' 17 + update-types: ['version-update:semver-major'] 18 18 labels: 19 - - "dependencies" 20 - - "security" 19 + - 'dependencies' 20 + - 'security'
+29 -7
src/app/c/[slug]/page.tsx
··· 2 2 * Category page - Shows topics for a specific category. 3 3 * URL: /c/{slug} 4 4 * Server-side rendered with SEO metadata and JSON-LD. 5 + * Maturity-aware: Adult categories are noindex'd, Mature get rating meta. 5 6 * @see specs/prd-web.md Section 3.1 6 7 */ 7 8 8 9 import type { Metadata } from 'next' 9 10 import { notFound } from 'next/navigation' 10 - import { getCategoryBySlug, getCategories, getTopics, ApiError } from '@/lib/api/client' 11 + import { 12 + getCategoryBySlug, 13 + getCategories, 14 + getTopics, 15 + getPublicSettings, 16 + ApiError, 17 + } from '@/lib/api/client' 18 + import { getEffectiveMaturity, getMaturityMeta, shouldIncludeOgTags } from '@/lib/seo' 11 19 12 20 export const dynamic = 'force-dynamic' 13 21 import { ForumLayout } from '@/components/layout/forum-layout' ··· 24 32 export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> { 25 33 const { slug } = await params 26 34 try { 27 - const category = await getCategoryBySlug(slug) 35 + const [category, publicSettings] = await Promise.all([ 36 + getCategoryBySlug(slug), 37 + getPublicSettings().catch(() => null), 38 + ]) 39 + 40 + const communityRating = publicSettings?.maturityRating ?? 'safe' 41 + const effectiveMaturity = getEffectiveMaturity(communityRating, category.maturityRating) 42 + const maturityMeta = getMaturityMeta(effectiveMaturity) 43 + const includeOg = shouldIncludeOgTags(effectiveMaturity) 44 + 28 45 return { 29 46 title: category.name, 30 47 description: category.description ?? `Topics in ${category.name}`, 31 48 alternates: { 32 49 canonical: `/c/${slug}`, 33 50 }, 34 - openGraph: { 35 - title: category.name, 36 - description: category.description ?? `Topics in ${category.name}`, 37 - type: 'website', 38 - }, 51 + ...(includeOg 52 + ? { 53 + openGraph: { 54 + title: category.name, 55 + description: category.description ?? `Topics in ${category.name}`, 56 + type: 'website', 57 + }, 58 + } 59 + : {}), 60 + ...maturityMeta, 39 61 } 40 62 } catch { 41 63 return { title: 'Category Not Found' }
+1 -1
src/app/robots.ts
··· 18 18 disallow: ['/admin/', '/auth/', '/api/', '/search', '/settings', '/notifications'], 19 19 }, 20 20 { 21 - userAgent: ['SemrushBot', 'AhrefsBot', 'MJ12bot'], 21 + userAgent: ['SemrushBot', 'AhrefsBot', 'MJ12bot', 'BLEXBot'], 22 22 disallow: '/', 23 23 }, 24 24 {
+113
src/app/sitemap.test.ts
··· 67 67 cid: 'bafyabc', 68 68 replyCount: 5, 69 69 reactionCount: 3, 70 + categoryMaturityRating: 'safe' as const, 70 71 lastActivityAt: '2025-06-15T12:00:00Z', 71 72 createdAt: '2025-06-01T00:00:00Z', 72 73 indexedAt: '2025-06-01T00:00:00Z', ··· 84 85 cid: 'bafydef', 85 86 replyCount: 0, 86 87 reactionCount: 1, 88 + categoryMaturityRating: 'safe' as const, 87 89 lastActivityAt: '2025-06-10T08:00:00Z', 88 90 createdAt: '2025-06-10T00:00:00Z', 89 91 indexedAt: '2025-06-10T00:00:00Z', ··· 160 162 // Should still return at least the homepage 161 163 expect(result.length).toBeGreaterThanOrEqual(1) 162 164 expect(result[0].url).toBe('https://barazo.forum') 165 + }) 166 + 167 + it('excludes adult-rated categories from sitemap', async () => { 168 + mockGetCategories.mockResolvedValue({ 169 + categories: [ 170 + { 171 + id: '1', 172 + slug: 'general', 173 + name: 'General', 174 + description: null, 175 + parentId: null, 176 + sortOrder: 0, 177 + communityDid: 'did:plc:test', 178 + maturityRating: 'safe' as const, 179 + createdAt: '2025-01-01T00:00:00Z', 180 + updatedAt: '2025-06-01T00:00:00Z', 181 + children: [], 182 + }, 183 + { 184 + id: '3', 185 + slug: 'adult-zone', 186 + name: 'Adult Zone', 187 + description: null, 188 + parentId: null, 189 + sortOrder: 1, 190 + communityDid: 'did:plc:test', 191 + maturityRating: 'adult' as const, 192 + createdAt: '2025-01-01T00:00:00Z', 193 + updatedAt: '2025-06-01T00:00:00Z', 194 + children: [], 195 + }, 196 + ], 197 + }) 198 + 199 + const result = await sitemap() 200 + const urls = result.map((entry) => entry.url) 201 + expect(urls).toContain('https://barazo.forum/c/general') 202 + expect(urls).not.toContain('https://barazo.forum/c/adult-zone') 203 + }) 204 + 205 + it('excludes topics with adult categoryMaturityRating from sitemap', async () => { 206 + mockGetTopics.mockResolvedValue({ 207 + topics: [ 208 + { 209 + uri: 'at://did:plc:test/forum.barazo.topic/safe1', 210 + rkey: 'safe1', 211 + authorDid: 'did:plc:author1', 212 + title: 'Safe Topic', 213 + content: 'Safe content', 214 + contentFormat: null, 215 + category: 'general', 216 + tags: null, 217 + communityDid: 'did:plc:test', 218 + cid: 'bafysafe', 219 + replyCount: 0, 220 + reactionCount: 0, 221 + categoryMaturityRating: 'safe' as const, 222 + lastActivityAt: '2025-06-15T12:00:00Z', 223 + createdAt: '2025-06-01T00:00:00Z', 224 + indexedAt: '2025-06-01T00:00:00Z', 225 + }, 226 + { 227 + uri: 'at://did:plc:test/forum.barazo.topic/adult1', 228 + rkey: 'adult1', 229 + authorDid: 'did:plc:author2', 230 + title: 'Adult Topic', 231 + content: 'Adult content', 232 + contentFormat: null, 233 + category: 'adult-zone', 234 + tags: null, 235 + communityDid: 'did:plc:test', 236 + cid: 'bafyadult', 237 + replyCount: 0, 238 + reactionCount: 0, 239 + categoryMaturityRating: 'adult' as const, 240 + lastActivityAt: '2025-06-10T08:00:00Z', 241 + createdAt: '2025-06-10T00:00:00Z', 242 + indexedAt: '2025-06-10T00:00:00Z', 243 + }, 244 + ], 245 + cursor: null, 246 + }) 247 + 248 + const result = await sitemap() 249 + const urls = result.map((entry) => entry.url) 250 + expect(urls).toContain('https://barazo.forum/t/safe-topic/safe1') 251 + expect(urls).not.toContain('https://barazo.forum/t/adult-topic/adult1') 252 + }) 253 + 254 + it('includes mature-rated categories in sitemap', async () => { 255 + mockGetCategories.mockResolvedValue({ 256 + categories: [ 257 + { 258 + id: '1', 259 + slug: 'mature-zone', 260 + name: 'Mature Zone', 261 + description: null, 262 + parentId: null, 263 + sortOrder: 0, 264 + communityDid: 'did:plc:test', 265 + maturityRating: 'mature' as const, 266 + createdAt: '2025-01-01T00:00:00Z', 267 + updatedAt: '2025-06-01T00:00:00Z', 268 + children: [], 269 + }, 270 + ], 271 + }) 272 + 273 + const result = await sitemap() 274 + const urls = result.map((entry) => entry.url) 275 + expect(urls).toContain('https://barazo.forum/c/mature-zone') 163 276 }) 164 277 })
+5 -2
src/app/sitemap.ts
··· 1 1 /** 2 2 * Dynamic sitemap generation. 3 3 * Includes homepage, categories (flattened tree), and topics. 4 + * Excludes Adult-rated categories and their topics. 4 5 * @see specs/prd-web.md Section 5 (Sitemaps) 5 6 */ 6 7 ··· 38 39 getTopics({ limit: 1000, sort: 'latest' }).catch(() => null), 39 40 ]) 40 41 41 - // Add category pages 42 + // Add category pages (exclude Adult-rated) 42 43 if (categoriesResult) { 43 44 const allCategories = flattenCategories(categoriesResult.categories) 44 45 for (const category of allCategories) { 46 + if (category.maturityRating === 'adult') continue 45 47 entries.push({ 46 48 url: `${SITE_URL}/c/${category.slug}`, 47 49 lastModified: new Date(category.updatedAt), ··· 51 53 } 52 54 } 53 55 54 - // Add topic pages 56 + // Add topic pages (exclude Adult-rated) 55 57 if (topicsResult) { 56 58 for (const topic of topicsResult.topics) { 59 + if (topic.categoryMaturityRating === 'adult') continue 57 60 entries.push({ 58 61 url: `${SITE_URL}/t/${slugify(topic.title)}/${topic.rkey}`, 59 62 lastModified: new Date(topic.lastActivityAt),
+47 -13
src/app/t/[slug]/[rkey]/page.tsx
··· 2 2 * Topic detail page - Shows topic post and threaded replies. 3 3 * URL: /t/{slug}/{rkey} 4 4 * Server-side rendered with JSON-LD DiscussionForumPosting. 5 + * Maturity-aware: Adult topics are noindex'd, Mature topics get rating meta. 5 6 * @see specs/prd-web.md Section 3.1, Section 5 6 7 */ 7 8 8 9 import type { Metadata } from 'next' 9 10 import { notFound } from 'next/navigation' 10 - import { getTopicByRkey, getCategories, getReplies, ApiError } from '@/lib/api/client' 11 + import { 12 + getTopicByRkey, 13 + getCategories, 14 + getReplies, 15 + getPublicSettings, 16 + ApiError, 17 + } from '@/lib/api/client' 11 18 import { slugify } from '@/lib/format' 19 + import { 20 + getEffectiveMaturity, 21 + getMaturityMeta, 22 + shouldIncludeJsonLd, 23 + shouldIncludeOgTags, 24 + } from '@/lib/seo' 12 25 import { ForumLayout } from '@/components/layout/forum-layout' 13 26 import { CategoryNav } from '@/components/category-nav' 14 27 import { Breadcrumbs } from '@/components/breadcrumbs' ··· 26 39 export async function generateMetadata({ params }: TopicPageProps): Promise<Metadata> { 27 40 const { rkey } = await params 28 41 try { 29 - const topic = await getTopicByRkey(rkey) 42 + const [topic, publicSettings] = await Promise.all([ 43 + getTopicByRkey(rkey), 44 + getPublicSettings().catch(() => null), 45 + ]) 30 46 const description = 31 47 topic.content.length > 160 ? topic.content.slice(0, 157) + '...' : topic.content 48 + 49 + const communityRating = publicSettings?.maturityRating ?? 'safe' 50 + const effectiveMaturity = getEffectiveMaturity(communityRating, topic.categoryMaturityRating) 51 + const maturityMeta = getMaturityMeta(effectiveMaturity) 52 + const includeOg = shouldIncludeOgTags(effectiveMaturity) 53 + 32 54 return { 33 55 title: topic.title, 34 56 description, 35 57 alternates: { 36 58 canonical: `/t/${slugify(topic.title)}/${rkey}`, 37 59 }, 38 - openGraph: { 39 - title: topic.title, 40 - description, 41 - type: 'article', 42 - publishedTime: topic.createdAt, 43 - }, 60 + ...(includeOg 61 + ? { 62 + openGraph: { 63 + title: topic.title, 64 + description, 65 + type: 'article', 66 + publishedTime: topic.createdAt, 67 + }, 68 + } 69 + : {}), 70 + ...maturityMeta, 44 71 } 45 72 } catch { 46 73 return { title: 'Topic Not Found' } ··· 61 88 } 62 89 throw error 63 90 } 91 + 92 + // Fetch community settings for maturity context 93 + const publicSettings = await getPublicSettings().catch(() => null) 94 + const communityRating = publicSettings?.maturityRating ?? 'safe' 95 + const effectiveMaturity = getEffectiveMaturity(communityRating, topic.categoryMaturityRating) 64 96 65 97 let categoriesResult: CategoriesResponse = { categories: [] } 66 98 let repliesResult: RepliesResponse = { replies: [], cursor: null } ··· 124 156 ) : undefined 125 157 } 126 158 > 127 - {/* JSON-LD */} 128 - <script 129 - type="application/ld+json" 130 - dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 131 - /> 159 + {/* JSON-LD: omitted for Adult content */} 160 + {shouldIncludeJsonLd(effectiveMaturity) && ( 161 + <script 162 + type="application/ld+json" 163 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 164 + /> 165 + )} 132 166 133 167 {/* Breadcrumbs */} 134 168 <Breadcrumbs items={breadcrumbItems} />
+5
src/lib/api/client.ts
··· 12 12 CommunitySettings, 13 13 CommunityStats, 14 14 CreateTopicInput, 15 + PublicSettings, 15 16 Topic, 16 17 TopicsResponse, 17 18 UpdatePreferencesInput, ··· 232 233 ...options, 233 234 headers: { ...options?.headers }, 234 235 }) 236 + } 237 + 238 + export function getPublicSettings(options?: FetchOptions): Promise<PublicSettings> { 239 + return apiFetch<PublicSettings>('/api/settings/public', options) 235 240 } 236 241 237 242 export function getCommunityStats(
+8
src/lib/api/types.ts
··· 46 46 cid: string 47 47 replyCount: number 48 48 reactionCount: number 49 + categoryMaturityRating: MaturityRating 49 50 lastActivityAt: string 50 51 createdAt: string 51 52 indexedAt: string ··· 161 162 requireLoginForMature: boolean 162 163 createdAt: string 163 164 updatedAt: string 165 + } 166 + 167 + export interface PublicSettings { 168 + communityName: string 169 + maturityRating: MaturityRating 170 + communityDescription: string | null 171 + communityLogoUrl: string | null 164 172 } 165 173 166 174 export interface CommunityStats {
+86
src/lib/seo.test.ts
··· 1 + /** 2 + * Tests for maturity-aware SEO helpers. 3 + * @see src/lib/seo.ts 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest' 7 + import { 8 + getEffectiveMaturity, 9 + getMaturityMeta, 10 + shouldIncludeJsonLd, 11 + shouldIncludeOgTags, 12 + } from './seo' 13 + 14 + describe('getEffectiveMaturity', () => { 15 + it('returns safe when both ratings are safe', () => { 16 + expect(getEffectiveMaturity('safe', 'safe')).toBe('safe') 17 + }) 18 + 19 + it('returns mature when community is safe and category is mature', () => { 20 + expect(getEffectiveMaturity('safe', 'mature')).toBe('mature') 21 + }) 22 + 23 + it('returns mature when community is mature and category is safe', () => { 24 + expect(getEffectiveMaturity('mature', 'safe')).toBe('mature') 25 + }) 26 + 27 + it('returns adult when community is adult and category is safe', () => { 28 + expect(getEffectiveMaturity('adult', 'safe')).toBe('adult') 29 + }) 30 + 31 + it('returns adult when community is safe and category is adult', () => { 32 + expect(getEffectiveMaturity('safe', 'adult')).toBe('adult') 33 + }) 34 + 35 + it('returns adult when both are adult', () => { 36 + expect(getEffectiveMaturity('adult', 'adult')).toBe('adult') 37 + }) 38 + 39 + it('returns adult when community is mature and category is adult', () => { 40 + expect(getEffectiveMaturity('mature', 'adult')).toBe('adult') 41 + }) 42 + }) 43 + 44 + describe('getMaturityMeta', () => { 45 + it('returns empty object for safe rating', () => { 46 + expect(getMaturityMeta('safe')).toEqual({}) 47 + }) 48 + 49 + it('returns rating meta for mature', () => { 50 + const meta = getMaturityMeta('mature') 51 + expect(meta).toEqual({ other: { rating: 'mature' } }) 52 + }) 53 + 54 + it('returns noindex/nofollow for adult', () => { 55 + const meta = getMaturityMeta('adult') 56 + expect(meta).toEqual({ robots: { index: false, follow: false } }) 57 + }) 58 + }) 59 + 60 + describe('shouldIncludeJsonLd', () => { 61 + it('returns true for safe', () => { 62 + expect(shouldIncludeJsonLd('safe')).toBe(true) 63 + }) 64 + 65 + it('returns true for mature', () => { 66 + expect(shouldIncludeJsonLd('mature')).toBe(true) 67 + }) 68 + 69 + it('returns false for adult', () => { 70 + expect(shouldIncludeJsonLd('adult')).toBe(false) 71 + }) 72 + }) 73 + 74 + describe('shouldIncludeOgTags', () => { 75 + it('returns true for safe', () => { 76 + expect(shouldIncludeOgTags('safe')).toBe(true) 77 + }) 78 + 79 + it('returns true for mature', () => { 80 + expect(shouldIncludeOgTags('mature')).toBe(true) 81 + }) 82 + 83 + it('returns false for adult', () => { 84 + expect(shouldIncludeOgTags('adult')).toBe(false) 85 + }) 86 + })
+60
src/lib/seo.ts
··· 1 + /** 2 + * Maturity-aware SEO helpers. 3 + * Determines meta tags, JSON-LD inclusion, and OG tag inclusion 4 + * based on content maturity ratings (safe/mature/adult). 5 + * @see decisions/frontend.md Section: Content Maturity + SEO 6 + */ 7 + 8 + import type { Metadata } from 'next' 9 + import type { MaturityRating } from '@/lib/api/types' 10 + 11 + /** Maturity tier ordering for comparison. */ 12 + const MATURITY_ORDER: Record<MaturityRating, number> = { 13 + safe: 0, 14 + mature: 1, 15 + adult: 2, 16 + } 17 + 18 + /** 19 + * Returns the higher (more restrictive) of two maturity ratings. 20 + * Used to combine community-level and category-level ratings. 21 + */ 22 + export function getEffectiveMaturity( 23 + communityRating: MaturityRating, 24 + categoryRating: MaturityRating 25 + ): MaturityRating { 26 + return MATURITY_ORDER[communityRating] >= MATURITY_ORDER[categoryRating] 27 + ? communityRating 28 + : categoryRating 29 + } 30 + 31 + /** 32 + * Returns metadata fields to merge based on the effective maturity rating. 33 + * - safe: no special tags 34 + * - mature: adds rating meta tag 35 + * - adult: sets noindex/nofollow 36 + */ 37 + export function getMaturityMeta(rating: MaturityRating): Partial<Metadata> { 38 + switch (rating) { 39 + case 'safe': 40 + return {} 41 + case 'mature': 42 + return { 43 + other: { rating: 'mature' }, 44 + } 45 + case 'adult': 46 + return { 47 + robots: { index: false, follow: false }, 48 + } 49 + } 50 + } 51 + 52 + /** Whether JSON-LD structured data should be included for this rating. */ 53 + export function shouldIncludeJsonLd(rating: MaturityRating): boolean { 54 + return rating !== 'adult' 55 + } 56 + 57 + /** Whether OpenGraph tags should be included for this rating. */ 58 + export function shouldIncludeOgTags(rating: MaturityRating): boolean { 59 + return rating !== 'adult' 60 + }
+5
src/mocks/data.ts
··· 144 144 cid: 'bafyreib1', 145 145 replyCount: 5, 146 146 reactionCount: 12, 147 + categoryMaturityRating: 'safe', 147 148 lastActivityAt: NOW, 148 149 createdAt: TWO_DAYS_AGO, 149 150 indexedAt: TWO_DAYS_AGO, ··· 161 162 cid: 'bafyreib2', 162 163 replyCount: 8, 163 164 reactionCount: 23, 165 + categoryMaturityRating: 'safe', 164 166 lastActivityAt: YESTERDAY, 165 167 createdAt: TWO_DAYS_AGO, 166 168 indexedAt: TWO_DAYS_AGO, ··· 178 180 cid: 'bafyreib3', 179 181 replyCount: 3, 180 182 reactionCount: 7, 183 + categoryMaturityRating: 'safe', 181 184 lastActivityAt: YESTERDAY, 182 185 createdAt: YESTERDAY, 183 186 indexedAt: YESTERDAY, ··· 195 198 cid: 'bafyreib4', 196 199 replyCount: 15, 197 200 reactionCount: 31, 201 + categoryMaturityRating: 'safe', 198 202 lastActivityAt: NOW, 199 203 createdAt: YESTERDAY, 200 204 indexedAt: YESTERDAY, ··· 212 216 cid: 'bafyreib5', 213 217 replyCount: 2, 214 218 reactionCount: 9, 219 + categoryMaturityRating: 'safe', 215 220 lastActivityAt: NOW, 216 221 createdAt: NOW, 217 222 indexedAt: NOW,