fix: doc rendering — markdown prose styling, code blocks with copy button, proper step parsing

- Add @tailwindcss/typography plugin for prose styling
- Create CodeBlock component with copy button and language labels
- Create Md wrapper component using ReactMarkdown with custom renderers
- Replace all plain text renders with Md for proper markdown formatting
- Fix parseSteps() in pipeline to group numbered steps with code blocks
- Add First Task subtitle explaining its purpose
- Add conditional file.purpose render in module key files
This commit is contained in:
Vectry
2026-02-09 18:18:24 +00:00
parent 029cd82f1a
commit cbe52f32b3
6 changed files with 182 additions and 30 deletions

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"@codeboard/shared": "*",
"@tailwindcss/typography": "^0.5.19",
"bullmq": "^5.34.0",
"ioredis": "^5.4.0",
"lucide-react": "^0.563.0",

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #0a0a0f;

View File

@@ -0,0 +1,55 @@
"use client";
import { useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
interface CodeBlockProps {
children: ReactNode;
className?: string;
inline?: boolean;
}
export function CodeBlock({ children, className, inline }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
if (inline) {
return (
<code className="px-1.5 py-0.5 rounded bg-white/10 text-blue-300 text-[0.85em] font-mono border border-white/5">
{children}
</code>
);
}
const language = className?.replace("language-", "") || "";
const codeString = String(children).replace(/\n$/, "");
const handleCopy = async () => {
await navigator.clipboard.writeText(codeString);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group my-4">
{language && (
<div className="absolute top-0 left-0 px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-zinc-500 bg-black/30 rounded-tl-lg rounded-br-lg border-b border-r border-white/5">
{language}
</div>
)}
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/5 border border-white/10 text-zinc-500 hover:text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 z-10"
aria-label="Copy code"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
<pre className="overflow-x-auto rounded-lg bg-black/50 border border-white/10 p-4 pt-8 text-sm leading-relaxed font-mono scrollbar-thin">
<code className={`text-zinc-300 ${className || ""}`}>{codeString}</code>
</pre>
</div>
);
}

View File

@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useState, type ComponentPropsWithoutRef } from "react";
import type { GeneratedDocs, DocsModule } from "@codeboard/shared";
import { MermaidDiagram } from "./mermaid-diagram";
import { CodeBlock } from "./code-block";
import ReactMarkdown from "react-markdown";
import {
BookOpen,
@@ -21,6 +22,31 @@ interface DocViewerProps {
docs: GeneratedDocs;
}
const markdownComponents = {
pre({ children, ...props }: ComponentPropsWithoutRef<"pre">) {
return <>{children}</>;
},
code({ children, className, ...props }: ComponentPropsWithoutRef<"code"> & { inline?: boolean }) {
const isBlock =
className?.startsWith("language-") ||
(typeof children === "string" && children.includes("\n"));
if (isBlock) {
return (
<CodeBlock className={className}>{children}</CodeBlock>
);
}
return <CodeBlock inline>{children}</CodeBlock>;
},
};
function Md({ children }: { children: string }) {
return (
<div className="prose prose-invert prose-sm max-w-none prose-p:text-zinc-300 prose-p:leading-relaxed prose-headings:text-white prose-strong:text-white prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline prose-li:text-zinc-300 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-transparent prose-pre:p-0">
<ReactMarkdown components={markdownComponents}>{children}</ReactMarkdown>
</div>
);
}
export function DocViewer({ docs }: DocViewerProps) {
const [activeSection, setActiveSection] = useState("overview");
const [expandedModules, setExpandedModules] = useState<Set<string>>(
@@ -116,9 +142,7 @@ export function DocViewer({ docs }: DocViewerProps) {
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-4">
{docs.sections.overview.title}
</h1>
<p className="text-lg text-zinc-400">
{docs.sections.overview.description}
</p>
<Md>{docs.sections.overview.description}</Md>
<div className="flex flex-wrap gap-2 mt-4">
{docs.sections.overview.techStack.map((tech: string) => (
<span
@@ -195,7 +219,9 @@ export function DocViewer({ docs }: DocViewerProps) {
{expandedModules.has(module.name) && (
<div className="px-5 pb-5 border-t border-white/10">
<p className="text-zinc-300 mt-4 mb-4">{module.summary}</p>
<div className="mt-4 mb-4">
<Md>{module.summary}</Md>
</div>
{module.keyFiles.length > 0 && (
<div className="mb-4">
@@ -211,7 +237,9 @@ export function DocViewer({ docs }: DocViewerProps) {
<FileCode className="w-4 h-4 text-zinc-500 mt-0.5 flex-shrink-0" />
<div>
<code className="text-blue-300">{file.path}</code>
{file.purpose && (
<p className="text-zinc-500">{file.purpose}</p>
)}
</div>
</div>
))}
@@ -252,11 +280,11 @@ export function DocViewer({ docs }: DocViewerProps) {
<div className="grid gap-6 md:grid-cols-2">
<div className="glass rounded-xl p-6">
<h3 className="font-semibold text-white mb-4">Coding Conventions</h3>
<ul className="space-y-2">
<ul className="space-y-3">
{docs.sections.patterns.conventions.map((convention: string, i: number) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-blue-400 mt-1"></span>
{convention}
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-blue-400 mt-1 flex-shrink-0"></span>
<Md>{convention}</Md>
</li>
))}
</ul>
@@ -264,11 +292,11 @@ export function DocViewer({ docs }: DocViewerProps) {
<div className="glass rounded-xl p-6">
<h3 className="font-semibold text-white mb-4">Design Patterns</h3>
<ul className="space-y-2">
<ul className="space-y-3">
{docs.sections.patterns.designPatterns.map((pattern: string, i: number) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-blue-400 mt-1"></span>
{pattern}
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-blue-400 mt-1 flex-shrink-0"></span>
<Md>{pattern}</Md>
</li>
))}
</ul>
@@ -282,9 +310,9 @@ export function DocViewer({ docs }: DocViewerProps) {
</h3>
<ul className="space-y-3">
{docs.sections.patterns.architecturalDecisions.map((decision: string, i: number) => (
<li key={i} className="flex items-start gap-3 text-zinc-300">
<li key={i} className="flex items-start gap-3">
<GitBranch className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
{decision}
<Md>{decision}</Md>
</li>
))}
</ul>
@@ -303,9 +331,9 @@ export function DocViewer({ docs }: DocViewerProps) {
<h3 className="font-semibold text-white mb-4">Prerequisites</h3>
<ul className="space-y-2">
{docs.sections.gettingStarted.prerequisites.map((prereq: string, i: number) => (
<li key={i} className="flex items-center gap-2 text-sm text-zinc-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
{prereq}
<li key={i} className="flex items-start gap-2 text-sm">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 mt-1.5 flex-shrink-0" />
<Md>{prereq}</Md>
</li>
))}
</ul>
@@ -313,25 +341,26 @@ export function DocViewer({ docs }: DocViewerProps) {
<div className="glass rounded-xl p-6">
<h3 className="font-semibold text-white mb-4">Setup Steps</h3>
<ol className="space-y-4">
<ol className="space-y-6">
{docs.sections.gettingStarted.setupSteps.map((step: string, i: number) => (
<li key={i} className="flex gap-4">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-300 text-sm flex items-center justify-center font-medium">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-blue-500/20 text-blue-300 text-sm flex items-center justify-center font-medium mt-0.5">
{i + 1}
</span>
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown>{step}</ReactMarkdown>
<div className="flex-1 min-w-0">
<Md>{step}</Md>
</div>
</li>
))}
</ol>
</div>
<div className="glass rounded-xl p-6 border-blue-500/20">
<h3 className="font-semibold text-white mb-3">First Task</h3>
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown>{docs.sections.gettingStarted.firstTask}</ReactMarkdown>
</div>
<div className="glass rounded-xl p-6 border border-blue-500/20">
<h3 className="font-semibold text-white mb-1">First Task</h3>
<p className="text-sm text-zinc-500 mb-4">
A suggested first contribution to help you learn the codebase
</p>
<Md>{docs.sections.gettingStarted.firstTask}</Md>
</div>
</div>
</section>

45
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"version": "0.0.1",
"dependencies": {
"@codeboard/shared": "*",
"@tailwindcss/typography": "^0.5.19",
"bullmq": "^5.34.0",
"ioredis": "^5.4.0",
"lucide-react": "^0.563.0",
@@ -1491,6 +1492,18 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
@@ -2236,6 +2249,18 @@
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5030,6 +5055,19 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/prisma": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
@@ -5421,7 +5459,6 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
@@ -5723,6 +5760,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",

View File

@@ -23,6 +23,29 @@ function parseList(text: string): string[] {
.filter(Boolean);
}
function parseSteps(text: string): string[] {
const lines = text.split("\n");
const steps: string[] = [];
let current = "";
for (const line of lines) {
if (/^\d+\.\s/.test(line)) {
if (current.trim()) {
steps.push(current.trim());
}
current = line.replace(/^\d+\.\s*/, "");
} else {
current += "\n" + line;
}
}
if (current.trim()) {
steps.push(current.trim());
}
return steps.filter(Boolean);
}
export async function generateDocumentation(
codeStructure: CodeStructure,
provider: LLMProvider,
@@ -112,7 +135,7 @@ export async function generateDocumentation(
const gsResponse = await provider.chat(gsMessages);
const prerequisites = parseList(parseSection(gsResponse, "Prerequisites"));
const setupSteps = parseList(parseSection(gsResponse, "Setup Steps"));
const setupSteps = parseSteps(parseSection(gsResponse, "Setup Steps"));
const firstTask = parseSection(gsResponse, "Your First Task");
onProgress?.("complete", 100);