diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 11bfd88..9173a19 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,20 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' open-pull-requests-limit: 10 groups: dependencies: patterns: - - "*" + - '*' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' ignore: - - dependency-name: "*" - update-types: ["version-update:semver-major"] + - dependency-name: '*' + update-types: ['version-update:semver-major'] labels: - - "dependencies" - - "security" + - 'dependencies' + - 'security' diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx index 0ab3b0f..f4a2de3 100644 --- a/src/app/c/[slug]/page.tsx +++ b/src/app/c/[slug]/page.tsx @@ -2,12 +2,20 @@ * Category page - Shows topics for a specific category. * URL: /c/{slug} * Server-side rendered with SEO metadata and JSON-LD. + * Maturity-aware: Adult categories are noindex'd, Mature get rating meta. * @see specs/prd-web.md Section 3.1 */ import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { getCategoryBySlug, getCategories, getTopics, ApiError } from '@/lib/api/client' +import { + getCategoryBySlug, + getCategories, + getTopics, + getPublicSettings, + ApiError, +} from '@/lib/api/client' +import { getEffectiveMaturity, getMaturityMeta, shouldIncludeOgTags } from '@/lib/seo' export const dynamic = 'force-dynamic' import { ForumLayout } from '@/components/layout/forum-layout' @@ -24,18 +32,32 @@ interface CategoryPageProps { export async function generateMetadata({ params }: CategoryPageProps): Promise { const { slug } = await params try { - const category = await getCategoryBySlug(slug) + const [category, publicSettings] = await Promise.all([ + getCategoryBySlug(slug), + getPublicSettings().catch(() => null), + ]) + + const communityRating = publicSettings?.maturityRating ?? 'safe' + const effectiveMaturity = getEffectiveMaturity(communityRating, category.maturityRating) + const maturityMeta = getMaturityMeta(effectiveMaturity) + const includeOg = shouldIncludeOgTags(effectiveMaturity) + return { title: category.name, description: category.description ?? `Topics in ${category.name}`, alternates: { canonical: `/c/${slug}`, }, - openGraph: { - title: category.name, - description: category.description ?? `Topics in ${category.name}`, - type: 'website', - }, + ...(includeOg + ? { + openGraph: { + title: category.name, + description: category.description ?? `Topics in ${category.name}`, + type: 'website', + }, + } + : {}), + ...maturityMeta, } } catch { return { title: 'Category Not Found' } diff --git a/src/app/robots.ts b/src/app/robots.ts index 47d4ce1..f80a675 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -18,7 +18,7 @@ export default function robots(): MetadataRoute.Robots { disallow: ['/admin/', '/auth/', '/api/', '/search', '/settings', '/notifications'], }, { - userAgent: ['SemrushBot', 'AhrefsBot', 'MJ12bot'], + userAgent: ['SemrushBot', 'AhrefsBot', 'MJ12bot', 'BLEXBot'], disallow: '/', }, { diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts index dbdadc7..63ecbb2 100644 --- a/src/app/sitemap.test.ts +++ b/src/app/sitemap.test.ts @@ -67,6 +67,7 @@ beforeEach(() => { cid: 'bafyabc', replyCount: 5, reactionCount: 3, + categoryMaturityRating: 'safe' as const, lastActivityAt: '2025-06-15T12:00:00Z', createdAt: '2025-06-01T00:00:00Z', indexedAt: '2025-06-01T00:00:00Z', @@ -84,6 +85,7 @@ beforeEach(() => { cid: 'bafydef', replyCount: 0, reactionCount: 1, + categoryMaturityRating: 'safe' as const, lastActivityAt: '2025-06-10T08:00:00Z', createdAt: '2025-06-10T00:00:00Z', indexedAt: '2025-06-10T00:00:00Z', @@ -161,4 +163,115 @@ describe('sitemap', () => { expect(result.length).toBeGreaterThanOrEqual(1) expect(result[0].url).toBe('https://barazo.forum') }) + + it('excludes adult-rated categories from sitemap', async () => { + 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: '3', + slug: 'adult-zone', + name: 'Adult Zone', + description: null, + parentId: null, + sortOrder: 1, + communityDid: 'did:plc:test', + maturityRating: 'adult' as const, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-06-01T00:00:00Z', + children: [], + }, + ], + }) + + const result = await sitemap() + const urls = result.map((entry) => entry.url) + expect(urls).toContain('https://barazo.forum/c/general') + expect(urls).not.toContain('https://barazo.forum/c/adult-zone') + }) + + it('excludes topics with adult categoryMaturityRating from sitemap', async () => { + mockGetTopics.mockResolvedValue({ + topics: [ + { + uri: 'at://did:plc:test/forum.barazo.topic/safe1', + rkey: 'safe1', + authorDid: 'did:plc:author1', + title: 'Safe Topic', + content: 'Safe content', + contentFormat: null, + category: 'general', + tags: null, + communityDid: 'did:plc:test', + cid: 'bafysafe', + replyCount: 0, + reactionCount: 0, + categoryMaturityRating: 'safe' as const, + 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/adult1', + rkey: 'adult1', + authorDid: 'did:plc:author2', + title: 'Adult Topic', + content: 'Adult content', + contentFormat: null, + category: 'adult-zone', + tags: null, + communityDid: 'did:plc:test', + cid: 'bafyadult', + replyCount: 0, + reactionCount: 0, + categoryMaturityRating: 'adult' as const, + lastActivityAt: '2025-06-10T08:00:00Z', + createdAt: '2025-06-10T00:00:00Z', + indexedAt: '2025-06-10T00:00:00Z', + }, + ], + cursor: null, + }) + + const result = await sitemap() + const urls = result.map((entry) => entry.url) + expect(urls).toContain('https://barazo.forum/t/safe-topic/safe1') + expect(urls).not.toContain('https://barazo.forum/t/adult-topic/adult1') + }) + + it('includes mature-rated categories in sitemap', async () => { + mockGetCategories.mockResolvedValue({ + categories: [ + { + id: '1', + slug: 'mature-zone', + name: 'Mature Zone', + description: null, + parentId: null, + sortOrder: 0, + communityDid: 'did:plc:test', + maturityRating: 'mature' as const, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-06-01T00:00:00Z', + children: [], + }, + ], + }) + + const result = await sitemap() + const urls = result.map((entry) => entry.url) + expect(urls).toContain('https://barazo.forum/c/mature-zone') + }) }) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 6539edf..05cb431 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,7 @@ /** * Dynamic sitemap generation. * Includes homepage, categories (flattened tree), and topics. + * Excludes Adult-rated categories and their topics. * @see specs/prd-web.md Section 5 (Sitemaps) */ @@ -38,10 +39,11 @@ export default async function sitemap(): Promise { getTopics({ limit: 1000, sort: 'latest' }).catch(() => null), ]) - // Add category pages + // Add category pages (exclude Adult-rated) if (categoriesResult) { const allCategories = flattenCategories(categoriesResult.categories) for (const category of allCategories) { + if (category.maturityRating === 'adult') continue entries.push({ url: `${SITE_URL}/c/${category.slug}`, lastModified: new Date(category.updatedAt), @@ -51,9 +53,10 @@ export default async function sitemap(): Promise { } } - // Add topic pages + // Add topic pages (exclude Adult-rated) if (topicsResult) { for (const topic of topicsResult.topics) { + if (topic.categoryMaturityRating === 'adult') continue entries.push({ url: `${SITE_URL}/t/${slugify(topic.title)}/${topic.rkey}`, lastModified: new Date(topic.lastActivityAt), diff --git a/src/app/t/[slug]/[rkey]/page.tsx b/src/app/t/[slug]/[rkey]/page.tsx index e41da70..201df6f 100644 --- a/src/app/t/[slug]/[rkey]/page.tsx +++ b/src/app/t/[slug]/[rkey]/page.tsx @@ -2,13 +2,26 @@ * Topic detail page - Shows topic post and threaded replies. * URL: /t/{slug}/{rkey} * Server-side rendered with JSON-LD DiscussionForumPosting. + * Maturity-aware: Adult topics are noindex'd, Mature topics get rating meta. * @see specs/prd-web.md Section 3.1, Section 5 */ import type { Metadata } from 'next' import { notFound } from 'next/navigation' -import { getTopicByRkey, getCategories, getReplies, ApiError } from '@/lib/api/client' +import { + getTopicByRkey, + getCategories, + getReplies, + getPublicSettings, + ApiError, +} from '@/lib/api/client' import { slugify } from '@/lib/format' +import { + getEffectiveMaturity, + getMaturityMeta, + shouldIncludeJsonLd, + shouldIncludeOgTags, +} from '@/lib/seo' import { ForumLayout } from '@/components/layout/forum-layout' import { CategoryNav } from '@/components/category-nav' import { Breadcrumbs } from '@/components/breadcrumbs' @@ -26,21 +39,35 @@ interface TopicPageProps { export async function generateMetadata({ params }: TopicPageProps): Promise { const { rkey } = await params try { - const topic = await getTopicByRkey(rkey) + const [topic, publicSettings] = await Promise.all([ + getTopicByRkey(rkey), + getPublicSettings().catch(() => null), + ]) const description = topic.content.length > 160 ? topic.content.slice(0, 157) + '...' : topic.content + + const communityRating = publicSettings?.maturityRating ?? 'safe' + const effectiveMaturity = getEffectiveMaturity(communityRating, topic.categoryMaturityRating) + const maturityMeta = getMaturityMeta(effectiveMaturity) + const includeOg = shouldIncludeOgTags(effectiveMaturity) + return { title: topic.title, description, alternates: { canonical: `/t/${slugify(topic.title)}/${rkey}`, }, - openGraph: { - title: topic.title, - description, - type: 'article', - publishedTime: topic.createdAt, - }, + ...(includeOg + ? { + openGraph: { + title: topic.title, + description, + type: 'article', + publishedTime: topic.createdAt, + }, + } + : {}), + ...maturityMeta, } } catch { return { title: 'Topic Not Found' } @@ -62,6 +89,11 @@ export default async function TopicPage({ params }: TopicPageProps) { throw error } + // Fetch community settings for maturity context + const publicSettings = await getPublicSettings().catch(() => null) + const communityRating = publicSettings?.maturityRating ?? 'safe' + const effectiveMaturity = getEffectiveMaturity(communityRating, topic.categoryMaturityRating) + let categoriesResult: CategoriesResponse = { categories: [] } let repliesResult: RepliesResponse = { replies: [], cursor: null } @@ -124,11 +156,13 @@ export default async function TopicPage({ params }: TopicPageProps) { ) : undefined } > - {/* JSON-LD */} -