Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/app/api/help/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server';
import type { BatchRequest, BatchResponse } from '@/lib/api/batch';

export const runtime = 'edge';

export interface HelpArticle {
id: string;
title: string;
content: string;
category: string;
tags: string[];
}

/** Static help content keyed by article id */
const HELP_ARTICLES: Record<string, HelpArticle> = {
'getting-started': {
id: 'getting-started',
title: 'Getting Started with TeachLink',
content:
'Welcome to TeachLink! Connect your Starknet wallet to begin exploring courses, earning reputation, and tipping creators.',
category: 'Onboarding',
tags: ['wallet', 'starknet', 'beginner'],
},
'wallet-connect': {
id: 'wallet-connect',
title: 'Connecting Your Wallet',
content:
'TeachLink supports Argent X and Braavos wallets. Click the "Connect Wallet" button in the top navigation to get started.',
category: 'Web3',
tags: ['wallet', 'argent', 'braavos'],
},
tipping: {
id: 'tipping',
title: 'How Tipping Works',
content:
'Send on-chain tips to course creators using STRK tokens. Tips are processed via smart contracts on Starknet.',
category: 'Web3',
tags: ['tips', 'strk', 'creators'],
},
courses: {
id: 'courses',
title: 'Browsing and Enrolling in Courses',
content:
'Browse courses by topic, filter by skill level, and enroll with a single click. Progress is tracked on-chain.',
category: 'Learning',
tags: ['courses', 'enroll', 'progress'],
},
reputation: {
id: 'reputation',
title: 'Building Your Reputation',
content:
'Earn reputation points by completing courses, contributing to discussions, and receiving tips from peers.',
category: 'Gamification',
tags: ['reputation', 'points', 'achievements'],
},
};

/**
* POST /api/help
*
* Accepts a batch of help article requests and returns all matching articles
* in a single response, reducing round-trips for the HelpDocumentation component.
*
* Body: { requests: BatchRequest[] }
* Response: { responses: BatchResponse<HelpArticle>[] }
*/
export async function POST(request: Request) {
let body: { requests: BatchRequest[] };

try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

if (!Array.isArray(body?.requests)) {
return NextResponse.json({ error: 'requests must be an array' }, { status: 400 });
}

const responses: BatchResponse<HelpArticle>[] = body.requests.map((req) => {
const article = HELP_ARTICLES[req.path];
if (!article) {
return { id: req.id, error: `Article not found: ${req.path}` };
}
return { id: req.id, data: article };
});

return NextResponse.json({ responses });
}

/**
* GET /api/help?ids=id1,id2
*
* Convenience endpoint for fetching multiple articles by comma-separated ids.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const ids = searchParams.get('ids')?.split(',').filter(Boolean) ?? [];

if (ids.length === 0) {
const all = Object.values(HELP_ARTICLES);
return NextResponse.json({ articles: all });
}

const articles = ids.map((id) => HELP_ARTICLES[id.trim()]).filter(Boolean);
return NextResponse.json({ articles });
}
50 changes: 50 additions & 0 deletions src/components/shared/MarkdownRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,54 @@ describe('markdownToHtml', () => {
// Verify we at least produce an img tag (format check)
expect(html).toContain('<img');
});

// ── GFM: Strikethrough ────────────────────────────────────────────────────

it('renders strikethrough with ~~', () => {
expect(markdownToHtml('This is ~~deleted~~ text')).toContain('<del>deleted</del>');
});

// ── GFM: Task lists ───────────────────────────────────────────────────────

it('renders unchecked task list item', () => {
const html = markdownToHtml('- [ ] Buy milk');
expect(html).toContain('<ul class="task-list">');
expect(html).toContain('type="checkbox"');
expect(html).toContain('Buy milk');
expect(html).not.toContain('checked');
});

it('renders checked task list item', () => {
const html = markdownToHtml('- [x] Done task');
expect(html).toContain('checked');
expect(html).toContain('Done task');
});

it('renders mixed task list', () => {
const md = '- [x] First\n- [ ] Second';
const html = markdownToHtml(md);
expect(html).toContain('<ul class="task-list">');
expect(html).toContain('First');
expect(html).toContain('Second');
});

// ── GFM: Tables ───────────────────────────────────────────────────────────

it('renders a simple GFM table', () => {
const md = '| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |';
const html = markdownToHtml(md);
expect(html).toContain('<table>');
expect(html).toContain('<thead>');
expect(html).toContain('<th>Name</th>');
expect(html).toContain('<th>Age</th>');
expect(html).toContain('<td>Alice</td>');
expect(html).toContain('<td>Bob</td>');
});

it('renders table with leading/trailing pipes', () => {
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
const html = markdownToHtml(md);
expect(html).toContain('<th>A</th>');
expect(html).toContain('<td>1</td>');
});
});
79 changes: 74 additions & 5 deletions src/components/shared/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import { useMemo } from 'react';
* - Headings: `# H1`, `## H2`, `### H3`
* - Bold: `**text**` or `__text__`
* - Italic: `*text*` or `_text_`
* - Strikethrough: `~~text~~` (GFM)
* - Inline code: `` `code` ``
* - Fenced code blocks: ` ```lang\n...\n``` `
* - Blockquotes: `> text`
* - Unordered lists: `- item` or `* item`
* - Task lists: `- [ ] todo` / `- [x] done` (GFM)
* - Ordered lists: `1. item`
* - Tables: GFM pipe tables
* - Links: `[label](url)`
* - Images: `![alt](url)`
* - Horizontal rules: `---`
Expand All @@ -33,6 +36,46 @@ export function markdownToHtml(markdown: string): string {
return `<pre><code${langAttr}>${escapeHtml(code.trimEnd())}</code></pre>`;
});

// GFM tables — must run before other block rules
html = html.replace(/((?:^[^\n]*\|[^\n]*(?:\n|$))+)/gm, (block) => {
const lines = block.trim().split('\n');
if (lines.length < 2) return block;
const isSeparator = (l: string) => /^[\s|:-]+$/.test(l);
const sepIdx = lines.findIndex(isSeparator);
if (sepIdx < 1) return block;

const parseRow = (line: string) =>
line
.replace(/^\||\|$/g, '')
.split('|')
.map((cell) => cell.trim());

const headers = parseRow(lines[0]);
const thead = `<thead><tr>${headers.map((h) => `<th>${h}</th>`).join('')}</tr></thead>`;
const bodyRows = lines
.slice(sepIdx + 1)
.filter((l) => l.trim() && !isSeparator(l))
.map(
(l) =>
`<tr>${parseRow(l)
.map((c) => `<td>${c}</td>`)
.join('')}</tr>`,
)
.join('');
return `<table>${thead}<tbody>${bodyRows}</tbody></table>`;
});

// Task lists — must run before unordered list rule
html = html.replace(/^[*-] \[( |x)\] (.+)$/gm, (_m, checked, label) => {
const attrs = checked === 'x' ? ' checked disabled' : ' disabled';
return `<li class="task-item"><input type="checkbox"${attrs} /> ${label}</li>`;
});

// Wrap consecutive task-list <li> items in <ul class="task-list">
html = html.replace(/((?:<li class="task-item">.*<\/li>\n?)+)/g, (block) => {
return `<ul class="task-list">${block}</ul>`;
});

// Headings
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
Expand Down Expand Up @@ -72,6 +115,9 @@ export function markdownToHtml(markdown: string): string {
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>');

// Strikethrough (GFM)
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');

// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');

Expand Down Expand Up @@ -133,12 +179,35 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr
if (typeof window === 'undefined') return raw;
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'strong', 'em', 'code', 'pre',
'ul', 'ol', 'li', 'blockquote', 'hr',
'a', 'img',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'br',
'strong',
'em',
'del',
'code',
'pre',
'ul',
'ol',
'li',
'blockquote',
'hr',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'input',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target', 'rel'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target', 'rel', 'type', 'checked', 'disabled'],
});
}, [content]);

Expand Down
121 changes: 121 additions & 0 deletions src/components/ui/HelpDocumentation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use client';

import { useState } from 'react';
import { HelpCircle, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
import { useHelpDocumentation } from '@/hooks/useHelpDocumentation';
import type { HelpArticle } from '@/hooks/useHelpDocumentation';

export interface HelpDocumentationProps {
/** Article ids to load on mount */
articleIds?: string[];
/** Optional heading shown above the article list */
title?: string;
className?: string;
}

function ArticleItem({ article }: { article: HelpArticle }) {
const [open, setOpen] = useState(false);

return (
<div className="border border-gray-200 rounded-lg dark:border-gray-700">
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-900 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 dark:text-gray-100 dark:hover:bg-gray-800"
>
<span className="flex items-center gap-2">
<HelpCircle size={16} aria-hidden="true" className="shrink-0 text-blue-500" />
{article.title}
</span>
{open ? (
<ChevronUp size={16} aria-hidden="true" className="shrink-0 text-gray-400" />
) : (
<ChevronDown size={16} aria-hidden="true" className="shrink-0 text-gray-400" />
)}
</button>

{open && (
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300">{article.content}</p>
{article.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1" aria-label="Tags">
{article.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-200"
>
{tag}
</span>
))}
</div>
)}
</div>
)}
</div>
);
}

/**
* HelpDocumentation
*
* Renders a list of collapsible help articles. Uses `useHelpDocumentation`
* which batches concurrent article requests into a single API call.
*/
export function HelpDocumentation({
articleIds = ['getting-started', 'wallet-connect', 'tipping', 'courses', 'reputation'],
title = 'Help & Documentation',
className = '',
}: HelpDocumentationProps) {
const { articles, loading, error, fetchArticles } = useHelpDocumentation(articleIds);

return (
<section
aria-label={title}
className={`rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900 ${className}`}
>
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-50">
<HelpCircle size={18} aria-hidden="true" className="text-blue-500" />
{title}
</h2>

{loading && (
<div role="status" aria-live="polite" className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-lg bg-gray-100 dark:bg-gray-800" />
))}
<span className="sr-only">Loading help articles…</span>
</div>
)}

{error && !loading && (
<div
role="alert"
className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
<AlertCircle size={16} aria-hidden="true" className="shrink-0" />
<span>{error}</span>
<button
onClick={() => fetchArticles(articleIds)}
className="ml-auto underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-red-500"
>
Retry
</button>
</div>
)}

{!loading && !error && articles.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">No help articles found.</p>
)}

{!loading && articles.length > 0 && (
<ul className="space-y-2" aria-label="Help articles">
{articles.map((article) => (
<li key={article.id}>
<ArticleItem article={article} />
</li>
))}
</ul>
)}
</section>
);
}
Loading
Loading