Barazo default frontend barazo.forum
2
fork

Configure Feed

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

fix(routes): support subcategory URLs with catch-all route (#186)

* chore: add .claude/worktrees to gitignore

* fix(routes): support subcategory URLs with catch-all route

Change category route from [slug] to [...slug] so subcategory paths
like /c/feedback/bug-reports resolve correctly instead of returning 404.
Sitemap now generates nested paths for subcategories.

* style(routes): fix prettier formatting in category page

authored by

Guido X Jansen and committed by
GitHub
65c65ffd 4d6cc171

+79 -23
src/app/c/[slug]/error.test.tsx src/app/c/[...slug]/error.test.tsx
src/app/c/[slug]/error.tsx src/app/c/[...slug]/error.tsx
src/app/c/[slug]/layout.tsx src/app/c/[...slug]/layout.tsx
+20 -1
src/app/c/[slug]/page.test.tsx src/app/c/[...slug]/page.test.tsx
··· 78 78 vi.mocked(getPublicSettings).mockResolvedValue(mockPublicSettings) 79 79 }) 80 80 81 - const params = Promise.resolve({ slug: 'general' }) 81 + const params = Promise.resolve({ slug: ['general'] }) 82 82 83 83 describe('CategoryPage', () => { 84 84 it('renders category name as heading', async () => { ··· 123 123 return data['@type'] === 'BreadcrumbList' 124 124 }) 125 125 expect(breadcrumbScript).toBeTruthy() 126 + }) 127 + 128 + it('renders subcategory with parent breadcrumb', async () => { 129 + const subcategoryData = { 130 + ...mockCategories[2]!.children[1]!, // bug-reports under feedback 131 + topicCount: 3, 132 + } 133 + mockGetCategoryBySlug.mockResolvedValue(subcategoryData) 134 + mockGetTopics.mockResolvedValue({ topics: [], cursor: null }) 135 + 136 + const subcategoryParams = Promise.resolve({ slug: ['feedback', 'bug-reports'] }) 137 + const page = await CategoryPage({ params: subcategoryParams }) 138 + render(page) 139 + 140 + expect(screen.getByRole('heading', { level: 1, name: 'Bug Reports' })).toBeInTheDocument() 141 + 142 + // Parent category should appear in breadcrumbs 143 + const breadcrumbNav = screen.getByRole('navigation', { name: /breadcrumb/i }) 144 + expect(breadcrumbNav).toHaveTextContent('Feedback & Ideas') 126 145 }) 127 146 })
+47 -15
src/app/c/[slug]/page.tsx src/app/c/[...slug]/page.tsx
··· 1 1 /** 2 2 * Category page - Shows topics for a specific category. 3 - * URL: /c/{slug} 3 + * URL: /c/{slug} or /c/{parentSlug}/{slug} for subcategories. 4 4 * Server-side rendered with SEO metadata and JSON-LD. 5 5 * Maturity-aware: Adult categories are noindex'd, Mature get rating meta. 6 6 * @see specs/prd-web.md Section 3.1 ··· 24 24 import { Breadcrumbs } from '@/components/breadcrumbs' 25 25 import { Pagination } from '@/components/pagination' 26 26 import { NewTopicButton } from '@/components/new-topic-button' 27 + import type { CategoryTreeNode } from '@/lib/api/types' 27 28 28 29 interface CategoryPageProps { 29 - params: Promise<{ slug: string }> 30 + params: Promise<{ slug: string[] }> 30 31 searchParams?: Promise<{ page?: string }> 31 32 } 32 33 34 + /** Find a category in the tree by slug. */ 35 + function findCategoryInTree( 36 + categories: CategoryTreeNode[], 37 + slug: string 38 + ): CategoryTreeNode | undefined { 39 + for (const cat of categories) { 40 + if (cat.slug === slug) return cat 41 + const found = findCategoryInTree(cat.children, slug) 42 + if (found) return found 43 + } 44 + return undefined 45 + } 46 + 33 47 export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> { 34 - const { slug } = await params 48 + const { slug: slugSegments } = await params 49 + const categorySlug = slugSegments.at(-1)! 50 + const canonicalPath = `/c/${slugSegments.join('/')}` 51 + 35 52 try { 36 53 const [category, publicSettings] = await Promise.all([ 37 - getCategoryBySlug(slug), 54 + getCategoryBySlug(categorySlug), 38 55 getPublicSettings().catch(() => null), 39 56 ]) 40 57 ··· 47 64 title: category.name, 48 65 description: category.description ?? `Topics in ${category.name}`, 49 66 alternates: { 50 - canonical: `/c/${slug}`, 67 + canonical: canonicalPath, 51 68 }, 52 69 ...(includeOg 53 70 ? { ··· 68 85 const TOPICS_PER_PAGE = 20 69 86 70 87 export default async function CategoryPage({ params, searchParams }: CategoryPageProps) { 71 - const { slug } = await params 88 + const { slug: slugSegments } = await params 89 + const categorySlug = slugSegments.at(-1)! 90 + const canonicalPath = `/c/${slugSegments.join('/')}` 72 91 const resolvedSearchParams = searchParams ? await searchParams : {} 73 92 const page = Math.max(1, parseInt(resolvedSearchParams.page ?? '1', 10) || 1) 74 93 75 94 let category 76 95 try { 77 - category = await getCategoryBySlug(slug) 96 + category = await getCategoryBySlug(categorySlug) 78 97 } catch (error) { 79 98 if (error instanceof ApiError && error.status === 404) { 80 99 notFound() ··· 85 104 const [categoriesResult, topicsResult, publicSettings] = await Promise.all([ 86 105 getCategories(), 87 106 getTopics({ 88 - category: slug, 107 + category: categorySlug, 89 108 limit: TOPICS_PER_PAGE, 90 109 }), 91 110 getPublicSettings().catch(() => null), ··· 93 112 94 113 const totalPages = Math.max(1, Math.ceil(category.topicCount / TOPICS_PER_PAGE)) 95 114 96 - const breadcrumbItems = [ 97 - { label: 'Home', href: '/' }, 98 - { label: category.name, href: `/c/${slug}` }, 99 - ] 115 + // Build breadcrumbs: Home > [Parent] > Category 116 + const breadcrumbItems = [{ label: 'Home', href: '/' }] 117 + 118 + if (slugSegments.length > 1) { 119 + const parentSlug = slugSegments[0]! 120 + const parentCategory = findCategoryInTree(categoriesResult.categories, parentSlug) 121 + breadcrumbItems.push({ 122 + label: parentCategory?.name ?? parentSlug, 123 + href: `/c/${parentSlug}`, 124 + }) 125 + } 126 + 127 + breadcrumbItems.push({ label: category.name, href: canonicalPath }) 100 128 101 129 return ( 102 130 <ForumLayout 103 131 publicSettings={publicSettings} 104 - sidebar={<CategoryNav categories={categoriesResult.categories} currentSlug={slug} />} 132 + sidebar={<CategoryNav categories={categoriesResult.categories} currentSlug={categorySlug} />} 105 133 > 106 134 {/* Breadcrumbs (includes JSON-LD BreadcrumbList) */} 107 135 <Breadcrumbs items={breadcrumbItems} /> ··· 119 147 120 148 {/* New topic button */} 121 149 <div className="mb-4 flex justify-end"> 122 - <NewTopicButton variant="category" categorySlug={slug} categoryName={category.name} /> 150 + <NewTopicButton 151 + variant="category" 152 + categorySlug={categorySlug} 153 + categoryName={category.name} 154 + /> 123 155 </div> 124 156 125 157 {/* Topic list */} ··· 128 160 {/* Pagination */} 129 161 {totalPages > 1 && ( 130 162 <div className="mt-6"> 131 - <Pagination currentPage={page} totalPages={totalPages} baseUrl={`/c/${slug}`} /> 163 + <Pagination currentPage={page} totalPages={totalPages} baseUrl={canonicalPath} /> 132 164 </div> 133 165 )} 134 166 </ForumLayout>
+2 -2
src/app/sitemap.test.ts
··· 118 118 const result = await sitemap() 119 119 const urls = result.map((entry) => entry.url) 120 120 expect(urls).toContain('https://barazo.forum/c/general') 121 - expect(urls).toContain('https://barazo.forum/c/introductions') 121 + expect(urls).toContain('https://barazo.forum/c/general/introductions') 122 122 }) 123 123 124 124 it('includes topic pages with author handle and rkey', async () => { ··· 163 163 const urls = result.map((entry) => entry.url) 164 164 // Both parent and child categories should be included 165 165 expect(urls).toContain('https://barazo.forum/c/general') 166 - expect(urls).toContain('https://barazo.forum/c/introductions') 166 + expect(urls).toContain('https://barazo.forum/c/general/introductions') 167 167 }) 168 168 169 169 it('handles API errors gracefully', async () => {
+10 -5
src/app/sitemap.ts
··· 12 12 13 13 const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' 14 14 15 - function flattenCategories(nodes: CategoryTreeNode[]): CategoryTreeNode[] { 16 - const result: CategoryTreeNode[] = [] 15 + interface FlatCategory extends CategoryTreeNode { 16 + path: string 17 + } 18 + 19 + function flattenCategories(nodes: CategoryTreeNode[], parentPath = ''): FlatCategory[] { 20 + const result: FlatCategory[] = [] 17 21 for (const node of nodes) { 18 - result.push(node) 22 + const path = parentPath ? `${parentPath}/${node.slug}` : node.slug 23 + result.push({ ...node, path }) 19 24 if (node.children.length > 0) { 20 - result.push(...flattenCategories(node.children)) 25 + result.push(...flattenCategories(node.children, path)) 21 26 } 22 27 } 23 28 return result ··· 45 50 for (const category of allCategories) { 46 51 if (category.maturityRating === 'adult') continue 47 52 entries.push({ 48 - url: `${SITE_URL}/c/${category.slug}`, 53 + url: `${SITE_URL}/c/${category.path}`, 49 54 lastModified: new Date(category.updatedAt), 50 55 changeFrequency: 'daily', 51 56 priority: 0.8,