feat: add markdown rendering for task descriptions and comments (closes #63)

This commit is contained in:
Brad Groux 2026-02-05 17:55:44 -06:00
parent 931d437b67
commit 9c2f193658
5 changed files with 1287 additions and 9 deletions

1148
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,9 @@
"nanoid": "^5.0.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.0"

View file

@ -0,0 +1,124 @@
/**
* MarkdownText - Renders markdown content with sanitization
*
* Converts markdown text to formatted HTML while maintaining XSS protection.
* Uses react-markdown for parsing (safe by default).
*/
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Components } from 'react-markdown';
interface MarkdownTextProps {
children: string;
className?: string;
}
/**
* Custom components for react-markdown to apply Tailwind styling
*/
const components: Components = {
// Headings
h1: ({ children }) => <h1 className="text-2xl font-bold mt-4 mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-xl font-bold mt-3 mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-lg font-semibold mt-3 mb-1">{children}</h3>,
h4: ({ children }) => <h4 className="text-base font-semibold mt-2 mb-1">{children}</h4>,
h5: ({ children }) => <h5 className="text-sm font-semibold mt-2 mb-1">{children}</h5>,
h6: ({ children }) => <h6 className="text-xs font-semibold mt-2 mb-1">{children}</h6>,
// Paragraphs
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// Lists
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-1">{children}</ol>,
li: ({ children }) => <li className="ml-4">{children}</li>,
// Code
code: ({ children, className }) => {
// Check if this is inline code by looking at parent node
// Inline code won't have a language class
const isInline = !className;
return isInline ? (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
) : (
<code
className={`block bg-muted p-3 rounded-md text-sm font-mono overflow-x-auto mb-2 ${className || ''}`}
>
{children}
</code>
);
},
pre: ({ children }) => <pre className="mb-2">{children}</pre>,
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-muted-foreground/30 pl-4 italic my-2 text-muted-foreground">
{children}
</blockquote>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline hover:text-primary/80"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Horizontal rule
hr: () => <hr className="border-border my-4" />,
// Tables
table: ({ children }) => (
<div className="overflow-x-auto mb-2">
<table className="border-collapse border border-border min-w-full">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border">{children}</tr>,
th: ({ children }) => (
<th className="border border-border px-3 py-2 text-left font-semibold">{children}</th>
),
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
// Emphasis
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
};
/**
* MarkdownText component
*
* Renders markdown content with GFM (GitHub Flavored Markdown) support.
* Automatically sanitizes HTML to prevent XSS attacks.
*
* @example
* ```tsx
* <MarkdownText>{task.description}</MarkdownText>
* ```
*/
export function MarkdownText({ children, className = '' }: MarkdownTextProps) {
if (!children) return null;
return (
<div className={`prose prose-sm max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={components}
// Use urlTransform to sanitize URLs - block javascript: and other dangerous protocols
urlTransform={(url) => {
if (url.match(/^\s*javascript:/i)) return '#';
return url;
}}
>
{children}
</ReactMarkdown>
</div>
);
}

View file

@ -14,9 +14,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { MarkdownText } from '@/components/shared/MarkdownText';
import { useAddComment, useEditComment, useDeleteComment } from '@/hooks/useTasks';
import type { Task, Comment } from '@veritas-kanban/shared';
import { sanitizeText } from '@/lib/sanitize';
interface CommentsSectionProps {
task: Task;
@ -144,9 +144,9 @@ function CommentItem({ comment, taskId }: { comment: Comment; taskId: string })
</div>
</div>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
{sanitizeText(comment.text)}
</p>
<div className="text-sm text-foreground break-words">
<MarkdownText>{comment.text}</MarkdownText>
</div>
)}
</div>
</div>
@ -246,7 +246,7 @@ export function CommentsSection({ task }: CommentsSectionProps) {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment... (Cmd/Ctrl+Enter to submit)"
placeholder="Add a comment... (supports Markdown, Cmd/Ctrl+Enter to submit)"
className="text-sm min-h-[80px] resize-none"
disabled={isAdding}
/>

View file

@ -20,11 +20,11 @@ import { TimeTrackingSection } from '../TimeTrackingSection';
import { CommentsSection } from '../CommentsSection';
import { BlockedReasonSection } from '../BlockedReasonSection';
import { LessonsLearnedSection } from '../LessonsLearnedSection';
import { MarkdownText } from '@/components/shared/MarkdownText';
import { useDeleteTask, useArchiveTask } from '@/hooks/useTasks';
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
import { Trash2, Archive, Calendar, Clock, RotateCcw } from 'lucide-react';
import type { Task, BlockedReason } from '@veritas-kanban/shared';
import { sanitizeText } from '@/lib/sanitize';
interface TaskDetailsTabProps {
task: Task;
@ -72,14 +72,18 @@ export function TaskDetailsTab({
<div className="space-y-2">
<Label className="text-muted-foreground">Description</Label>
{readOnly ? (
<div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
{sanitizeText(task.description || '') || 'No description'}
<div className="text-sm text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
{task.description ? (
<MarkdownText>{task.description}</MarkdownText>
) : (
<span className="text-muted-foreground italic">No description</span>
)}
</div>
) : (
<Textarea
value={task.description}
onChange={(e) => onUpdate('description', e.target.value)}
placeholder="Add a description..."
placeholder="Add a description... (supports Markdown)"
rows={4}
className="resize-none"
/>