PBI-58: Developer manual + in-app /manual page (#148)
* docs(PBI-58): add developer manual chapters under docs/manual/ Adds a 7-file English-language manual targeted at new human contributors: index, overview, statuses & transitions (with mermaid state diagrams), git workflow, MCP integration, docker, and troubleshooting. The manual is the *map* — it cross-references existing runbooks/ADRs/architecture docs rather than duplicating their content. Regenerates docs/INDEX.md and validates with check-doc-links.mjs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(PBI-58): add markdown rendering deps + manual:build script Adds mermaid, rehype-slug, rehype-autolink-headings for the in-app /manual page. Wires manual:build into prebuild so production builds always regenerate the chapter TOC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-58): codegen script for in-app manual TOC scripts/build-manual.mjs walks docs/manual/, parses YAML front-matter, strips it from the body, and emits lib/manual.generated.ts with a typed ManualEntry[] containing slug, title, description, filePath, and the embedded markdown body. Pure Node 20, mirrors generate-docs-index.mjs. Inlining the markdown at build time keeps runtime serverless functions free of filesystem reads, which avoids whole-project NFT tracing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat(PBI-58): add Manual link to main nav bar Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d750676f5e
commit
bd7478861b
19 changed files with 2239 additions and 105 deletions
42
app/(app)/manual/[[...slug]]/page.tsx
Normal file
42
app/(app)/manual/[[...slug]]/page.tsx
Normal file
|
|
@ -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<Params[]> {
|
||||
return getManualToc().map((entry) => ({
|
||||
slug: entry.slug.length > 0 ? [...entry.slug] : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>
|
||||
}): Promise<Metadata> {
|
||||
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<Params>
|
||||
}) {
|
||||
const { slug = [] } = await params
|
||||
const chapter = getManualChapter(slug)
|
||||
if (!chapter) notFound()
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-6 py-8">
|
||||
<MarkdownView markdown={chapter.body} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
app/(app)/manual/_components/manual-sidebar.tsx
Normal file
51
app/(app)/manual/_components/manual-sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<nav
|
||||
aria-label="Manual chapters"
|
||||
className="sticky top-20 hidden h-[calc(100vh-6rem)] w-64 shrink-0 overflow-y-auto border-r border-border px-4 py-6 lg:block"
|
||||
>
|
||||
<p className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Manual
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{toc.map((entry) => {
|
||||
const href = entryHref(entry)
|
||||
const isActive = pathname === href
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'block rounded-md px-3 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
42
app/(app)/manual/_components/markdown-view.tsx
Normal file
42
app/(app)/manual/_components/markdown-view.tsx
Normal file
|
|
@ -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 (
|
||||
<article className="prose prose-neutral max-w-none dark:prose-invert prose-headings:scroll-mt-20 prose-a:text-primary prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-sm prose-pre:bg-muted prose-pre:text-foreground">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeSlug,
|
||||
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
|
||||
]}
|
||||
components={{
|
||||
code(props) {
|
||||
const { className, children } = props as {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
const match = /language-(\w+)/.exec(className ?? '')
|
||||
const lang = match?.[1]
|
||||
const text = String(children ?? '').replace(/\n$/, '')
|
||||
if (lang === 'mermaid') {
|
||||
return <MermaidBlock source={text} />
|
||||
}
|
||||
return (
|
||||
<code className={className}>{children}</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
73
app/(app)/manual/_components/mermaid-block.tsx
Normal file
73
app/(app)/manual/_components/mermaid-block.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
source: string
|
||||
}
|
||||
|
||||
let mermaidPromise: Promise<typeof import('mermaid').default> | 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<HTMLDivElement | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs text-destructive">
|
||||
<code>
|
||||
{`Mermaid render failed: ${error}\n\n${source}`}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="my-4 flex justify-center overflow-x-auto rounded-md bg-muted p-4 [&_svg]:max-w-full"
|
||||
aria-label="Diagram"
|
||||
/>
|
||||
)
|
||||
}
|
||||
16
app/(app)/manual/layout.tsx
Normal file
16
app/(app)/manual/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex w-full">
|
||||
<ManualSidebar toc={toc} />
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue