diff --git a/docs/plans/2026-05-07-url-driven-section-routing.md b/docs/plans/2026-05-07-url-driven-section-routing.md new file mode 100644 index 0000000..37d8bd0 --- /dev/null +++ b/docs/plans/2026-05-07-url-driven-section-routing.md @@ -0,0 +1,882 @@ +# URL-Driven Section Routing Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the single-page client-state accordion with URL-driven routing — each section gets its own static URL (`/intro`, `/bio`, etc.), clicking a section heading navigates between pages. + +**Architecture:** `app/[[...slug]]/page.tsx` is the single RSC that handles all routes. It resolves the active slug from URL params (defaulting to first section at `/`), then passes `activeSlug` down to `SectionsAccordion` (now a server component). `SectionAccordion` entity renders inactive sections as `` elements. `SidebarNav` uses `usePathname()` for active state. + +**Tech Stack:** Next.js 16 App Router, React 19, Vitest + RTL, TypeScript strict, Biome, `next/link`, `next/navigation`. + +--- + +## Task 0: Commit next.config.ts fix + +The `output: 'export'` was already made conditional on `NODE_ENV === 'production'` to allow route handlers in dev. Commit it. + +**Files:** +- Already modified: `next.config.ts` + +**Step 1: Verify file is correct** + +`next.config.ts` should read: +```ts +import type { NextConfig } from 'next'; + +const isExport = process.env.NODE_ENV === 'production'; + +const nextConfig: NextConfig = { + /* output: 'export' only applies at build time — enabling it in dev mode + * breaks route handlers (incompatible with force-dynamic in Next.js 16) */ + ...(isExport ? { output: 'export' } : {}), + images: { unoptimized: true }, +}; + +export default nextConfig; +``` + +**Step 2: Commit** + +```bash +git add next.config.ts +git commit -m "fix: make output export build-only so dev route handlers work" +``` + +--- + +## Task 1: Update SectionAccordion entity — onClick → href (TDD) + +Replace the inactive ` + + {isOpen && ( +
+ {items.map((item) => ( + setIsOpen(false)} + className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3" + > +
+ {item.number} + + {item.label} + +
+ + ))} +
+ )} + + ); +} +``` + +**Step 4: Run tests — verify they pass** + +```bash +yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run +``` + +Expected: all PASS. + +**Step 5: Commit** + +```bash +git add src/widgets/Navigation/ui/MobileNav.tsx src/widgets/Navigation/ui/MobileNav.test.tsx +git commit -m "refactor: MobileNav section items use Link instead of scrollToSection" +``` + +--- + +## Task 5: Create [[...slug]]/page.tsx and delete app/page.tsx + +Wire everything together with `generateStaticParams` for SSG. + +**Files:** +- Create: `app/[[...slug]]/page.tsx` +- Delete: `app/page.tsx` + +**Step 1: Create the route file** + +Create `app/[[...slug]]/page.tsx`: + +```tsx +import { notFound } from 'next/navigation'; +import type { SectionRecord } from '$entities/Section'; +import { getCollection } from '$shared/api'; +import type { NavItem } from '$widgets/Navigation'; +import { MobileNav, SidebarNav } from '$widgets/Navigation'; +import { SectionFactory } from '$widgets/SectionFactory'; +import { SectionsAccordion } from '$widgets/SectionsAccordion'; + +/** + * Generates static params for all section pages plus the root. + */ +export async function generateStaticParams() { + const { items: sections } = await getCollection('sections', { + sort: 'order', + }); + return [ + {}, + ...sections.map((s) => ({ slug: [s.slug] })), + ]; +} + +/** + * Portfolio page — handles all section routes via optional catchall. + * + * `/` → first section shown as active + * `/{slug}` → that section shown as active + */ +export default async function SectionPage({ + params, +}: { + params: Promise<{ slug?: string[] }>; +}) { + const { slug } = await params; + + const { items: sections } = await getCollection('sections', { + sort: 'order', + }); + + if (!sections.length) notFound(); + + const activeSlug = slug?.[0] ?? sections[0].slug; + + if (!sections.find((s) => s.slug === activeSlug)) notFound(); + + const navItems: NavItem[] = sections.map((s) => ({ + id: s.slug, + label: s.title, + number: s.number, + })); + + return ( +
+ +
+ + + + +
+
+ ); +} +``` + +**Step 2: Delete app/page.tsx** + +```bash +rm app/page.tsx +``` + +**Step 3: TypeScript check** + +```bash +yarn tsc --noEmit +``` + +Expected: no errors. + +**Step 4: Commit** + +```bash +git add app/ +git commit -m "feat: URL-driven section routing via optional catchall with generateStaticParams" +``` + +--- + +## Task 6: Final Verification + +**Step 1: Full test suite** + +```bash +yarn test --run +``` + +Expected: all tests PASS. + +**Step 2: TypeScript check** + +```bash +yarn tsc --noEmit +``` + +Expected: no errors. + +**Step 3: Lint check** + +```bash +yarn check +``` + +Expected: no errors. If auto-fixable issues: `yarn check:fix` then re-run. + +**Step 4: Dev server smoke test** + +```bash +yarn dev +``` + +Open `http://localhost:3000` — should show first section (intro) as active, others as links. Clicking a section link should change the URL and show that section's content.