From 797c7d32b0450012933a95caa1a4535974661852 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:43:47 +0200 Subject: [PATCH] feat(PBI-58): /manual route renders developer manual chapters in-app Catch-all route at app/(app)/manual/[[...slug]]/page.tsx with generateStaticParams covering every TOC entry. Server-side MarkdownView uses react-markdown with remark-gfm, rehype-slug, and rehype-autolink-headings; mermaid code blocks are routed to a client-only MermaidBlock that dynamic-imports mermaid on mount. ManualSidebar (client) reads the typed TOC and highlights the active chapter via usePathname. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/manual/[[...slug]]/page.tsx | 42 +++++++++++ .../manual/_components/manual-sidebar.tsx | 51 +++++++++++++ .../manual/_components/markdown-view.tsx | 42 +++++++++++ .../manual/_components/mermaid-block.tsx | 73 +++++++++++++++++++ app/(app)/manual/layout.tsx | 16 ++++ lib/manual-server.ts | 28 +++++++ 6 files changed, 252 insertions(+) create mode 100644 app/(app)/manual/[[...slug]]/page.tsx create mode 100644 app/(app)/manual/_components/manual-sidebar.tsx create mode 100644 app/(app)/manual/_components/markdown-view.tsx create mode 100644 app/(app)/manual/_components/mermaid-block.tsx create mode 100644 app/(app)/manual/layout.tsx create mode 100644 lib/manual-server.ts diff --git a/app/(app)/manual/[[...slug]]/page.tsx b/app/(app)/manual/[[...slug]]/page.tsx new file mode 100644 index 0000000..ccc330b --- /dev/null +++ b/app/(app)/manual/[[...slug]]/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getManualChapter, getManualToc } from '@/lib/manual-server' +import { MarkdownView } from '../_components/markdown-view' + +type Params = { slug?: string[] } + +export async function generateStaticParams(): Promise { + return getManualToc().map((entry) => ({ + slug: entry.slug.length > 0 ? [...entry.slug] : undefined, + })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise +}): Promise { + const { slug = [] } = await params + const chapter = getManualChapter(slug) + if (!chapter) return { title: 'Manual — not found' } + return { + title: `${chapter.entry.title} — Scrum4Me Manual`, + description: chapter.entry.description.slice(0, 200), + } +} + +export default async function ManualChapterPage({ + params, +}: { + params: Promise +}) { + const { slug = [] } = await params + const chapter = getManualChapter(slug) + if (!chapter) notFound() + + return ( +
+ +
+ ) +} diff --git a/app/(app)/manual/_components/manual-sidebar.tsx b/app/(app)/manual/_components/manual-sidebar.tsx new file mode 100644 index 0000000..9643ed3 --- /dev/null +++ b/app/(app)/manual/_components/manual-sidebar.tsx @@ -0,0 +1,51 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' +import type { ManualEntry } from '@/lib/manual.generated' + +type Props = { + toc: readonly ManualEntry[] +} + +function entryHref(entry: ManualEntry): string { + if (entry.slug.length === 0) return '/manual' + return '/manual/' + entry.slug.join('/') +} + +export function ManualSidebar({ toc }: Props) { + const pathname = usePathname() + + return ( + + ) +} diff --git a/app/(app)/manual/_components/markdown-view.tsx b/app/(app)/manual/_components/markdown-view.tsx new file mode 100644 index 0000000..421477f --- /dev/null +++ b/app/(app)/manual/_components/markdown-view.tsx @@ -0,0 +1,42 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeSlug from 'rehype-slug' +import rehypeAutolinkHeadings from 'rehype-autolink-headings' +import { MermaidBlock } from './mermaid-block' + +type Props = { + markdown: string +} + +export function MarkdownView({ markdown }: Props) { + return ( +
+ + } + return ( + {children} + ) + }, + }} + > + {markdown} + +
+ ) +} diff --git a/app/(app)/manual/_components/mermaid-block.tsx b/app/(app)/manual/_components/mermaid-block.tsx new file mode 100644 index 0000000..66e52db --- /dev/null +++ b/app/(app)/manual/_components/mermaid-block.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useId, useRef, useState } from 'react' + +type Props = { + source: string +} + +let mermaidPromise: Promise | null = null + +function loadMermaid() { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then((mod) => { + const mermaid = mod.default + mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'strict', + fontFamily: 'inherit', + }) + return mermaid + }) + } + return mermaidPromise +} + +export function MermaidBlock({ source }: Props) { + const id = useId().replace(/[^a-zA-Z0-9]/g, '') + const containerRef = useRef(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + loadMermaid() + .then(async (mermaid) => { + if (cancelled) return + try { + const { svg } = await mermaid.render(`mermaid-${id}`, source) + if (cancelled) return + if (containerRef.current) containerRef.current.innerHTML = svg + setError(null) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + } + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [id, source]) + + if (error) { + return ( +
+        
+          {`Mermaid render failed: ${error}\n\n${source}`}
+        
+      
+ ) + } + + return ( +
+ ) +} diff --git a/app/(app)/manual/layout.tsx b/app/(app)/manual/layout.tsx new file mode 100644 index 0000000..06ebc7e --- /dev/null +++ b/app/(app)/manual/layout.tsx @@ -0,0 +1,16 @@ +import { getManualToc } from '@/lib/manual-server' +import { ManualSidebar } from './_components/manual-sidebar' + +export default function ManualLayout({ + children, +}: { + children: React.ReactNode +}) { + const toc = getManualToc() + return ( +
+ +
{children}
+
+ ) +} diff --git a/lib/manual-server.ts b/lib/manual-server.ts new file mode 100644 index 0000000..aff4dca --- /dev/null +++ b/lib/manual-server.ts @@ -0,0 +1,28 @@ +import { MANUAL_TOC, type ManualEntry } from './manual.generated' + +export type { ManualEntry } from './manual.generated' + +export type ManualChapter = { + entry: ManualEntry + body: string +} + +export function getManualToc(): readonly ManualEntry[] { + return MANUAL_TOC +} + +function slugMatches(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + +export function findManualEntry(slug: readonly string[]): ManualEntry | null { + return MANUAL_TOC.find((e) => slugMatches(e.slug, slug)) ?? null +} + +export function getManualChapter(slug: readonly string[]): ManualChapter | null { + const entry = findManualEntry(slug) + if (!entry) return null + return { entry, body: entry.markdown } +}