workshopclaudecode/src/lib/markdown/render.jsx
Frank Meeuwsen 44f75914cc feat: Worksheet pagina met workshop materiaal
- /worksheet route met downloads, start prompt en tips
- Custom directives: tip block, command block updates
- Superpowers zip download voor deelnemers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:25:37 +02:00

374 lines
11 KiB
JavaScript

/* eslint-disable react-refresh/only-export-components */
/**
* render.jsx - Markdown-naar-React renderer
*
* Rendert de block-array uit directives.js naar React elementen.
* Elke block-type mapped naar bestaande Tailwind classes uit het project.
* Afbeeldingpaden worden automatisch geprefixed met BASE_URL.
*/
import { useState, useCallback } from 'react';
import { marked } from 'marked';
const BASE_URL = import.meta.env.BASE_URL;
// Map van directive keys naar context property namen
const CONTEXT_KEY_MAP = { os: 'activeOS' };
/**
* Haalt de context-waarde op voor een directive key (bijv. "os" -> context.activeOS).
*/
function getContextValue(key, context) {
const contextKey = CONTEXT_KEY_MAP[key];
return contextKey ? context[contextKey] : context[key];
}
/**
* Rendert een array van blocks naar React elementen.
*
* @param {Array} blocks - Geparsde block-array uit parseDocument()
* @param {object} context - Gedeelde state: { activeOS, setActiveOS }
* @returns {JSX.Element[]}
*/
export function renderBlocks(blocks, context) {
return blocks.map((block, index) => (
<RenderBlock key={index} block={block} context={context} />
));
}
function RenderBlock({ block, context }) {
switch (block.type) {
case 'markdown':
return <MarkdownBlock content={block.content} />;
case 'tabs':
return <TabsBlock block={block} context={context} />;
case 'accordion':
return <AccordionBlock block={block} context={context} />;
case 'warning':
return <WarningBlock content={block.content} />;
case 'info':
return <InfoBlock content={block.content} />;
case 'tip':
return <TipBlock content={block.content} />;
case 'steps':
return <StepsBlock block={block} context={context} />;
case 'checklist':
return <ChecklistBlock content={block.content} />;
case 'checklist-verify':
return <ChecklistVerifyBlock content={block.content} />;
case 'command':
return <CommandBlock command={block.command} content={block.content} />;
case 'conditional':
return <ConditionalBlock block={block} context={context} />;
case 'conditional-image':
return <ConditionalImageBlock block={block} context={context} />;
case 'troubleshoot':
return <TroubleshootBlock title={block.title} content={block.content} />;
default:
return null;
}
}
/**
* Zet Markdown om naar HTML met marked, fixt afbeeldingpaden.
*/
function renderMarkdown(content) {
let html = marked.parse(content, { breaks: false });
// Prefix afbeeldingpaden met BASE_URL
html = html.replace(
/src="(?!https?:\/\/|data:)([^"]+)"/g,
`src="${BASE_URL}$1"`
);
return html;
}
/**
* Gewone Markdown paragrafen, headings, links, etc.
*/
function MarkdownBlock({ content }) {
return (
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
);
}
/**
* OS-tabbladen (Mac/Windows).
* Hergebruikt de coral button styling uit de originele pagina.
*/
function TabsBlock({ block, context }) {
const { activeOS, setActiveOS } = context;
const tabMap = { Mac: 'mac', Windows: 'windows' };
return (
<div className="mb-6">
<div className="flex gap-2 mb-6">
{block.tabs.map((tab) => {
const osKey = tabMap[tab.label] || tab.label.toLowerCase();
const isActive = activeOS === osKey;
return (
<button
key={tab.label}
onClick={() => setActiveOS(osKey)}
className={`px-5 py-2 rounded-lg font-semibold transition-all ${
isActive
? 'bg-coral-500 text-white'
: 'bg-white text-warm-700 border border-warm-200 hover:border-warm-300'
}`}
>
{tab.label}
</button>
);
})}
</div>
{block.tabs.map((tab) => {
const osKey = tabMap[tab.label] || tab.label.toLowerCase();
if (activeOS !== osKey) return null;
return (
<div key={tab.label} className="card">
{renderBlocks(tab.blocks, context)}
</div>
);
})}
</div>
);
}
/**
* Uitklapbare accordion sectie.
*/
function AccordionBlock({ block, context }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white rounded-xl border border-warm-200 overflow-hidden mb-6">
<button
onClick={() => setOpen(!open)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-warm-50 transition-colors"
aria-expanded={open}
>
<span className="heading-3">{block.title}</span>
<svg
className={`w-5 h-5 text-coral-500 flex-shrink-0 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-6 pb-6 space-y-4">
{renderBlocks(block.blocks, context)}
</div>
)}
</div>
);
}
/**
* Waarschuwingsblok (teal achtergrond).
*/
function WarningBlock({ content }) {
return (
<div className="bg-teal-50 border border-teal-200 rounded-lg px-4 py-3 mb-6">
<div
className="prose-installatie prose-warning"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}
/**
* Informatieblok (warm achtergrond).
*/
function InfoBlock({ content }) {
return (
<div className="bg-warm-100 border border-warm-200 rounded-lg px-4 py-3 mb-6">
<div
className="prose-installatie prose-info"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}
/**
* Tipblok (gele achtergrond met lampje).
*/
function TipBlock({ content }) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-6 flex gap-3">
<span className="text-xl leading-6 shrink-0">💡</span>
<div
className="prose-installatie prose-info"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}
/**
* Genummerde stappen met coral cirkel-iconen.
* Gebruikt pre-parsed steps uit de directive parser.
* Elke stap heeft: number, text (eerste regel), blocks (geneste directives).
*/
function StepsBlock({ block, context }) {
return (
<ol className="space-y-4 text-warm-600">
{block.steps.map((step) => (
<li key={step.number} className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">
{step.number}
</span>
<div className="flex-1">
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(step.text) }}
/>
{step.blocks.length > 0 && renderBlocks(step.blocks, context)}
</div>
</li>
))}
</ol>
);
}
/**
* Checklijst met coral vink-iconen.
*/
function ChecklistBlock({ content }) {
const items = content
.split('\n')
.filter(line => line.match(/^-\s/))
.map(line => line.replace(/^-\s+/, ''));
return (
<div className="card">
<ul className="space-y-4">
{items.map((item, i) => (
<li key={i} className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(item) }}
/>
</li>
))}
</ul>
</div>
);
}
/**
* Verificatie checklijst met teal vinkjes.
*/
function ChecklistVerifyBlock({ content }) {
const items = content
.split('\n')
.filter(line => line.match(/^-\s/))
.map(line => line.replace(/^-\s+/, ''));
return (
<ul className="space-y-2">
{items.map((item, i) => (
<li key={i} className="flex gap-2 text-warm-600">
<svg className="w-5 h-5 text-teal-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(item) }} />
</li>
))}
</ul>
);
}
/**
* Terminal commando blok (donkere achtergrond) met kopieerknop.
*/
function CommandBlock({ command, content }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [content]);
return (
<div className="mb-6">
<div className="bg-warm-900 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-warm-700">
<span className="text-warm-400 text-sm font-medium">{command}</span>
<button
onClick={handleCopy}
className="text-warm-400 hover:text-white text-sm transition-colors flex items-center gap-1"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Gekopieerd!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Kopieer
</>
)}
</button>
</div>
<pre className="text-warm-100 px-4 py-3 font-mono text-sm whitespace-pre-wrap">{content}</pre>
</div>
</div>
);
}
/**
* Conditioneel blok - toont alleen als de context-waarde matcht.
*/
function ConditionalBlock({ block, context }) {
const contextValue = getContextValue(block.key, context);
if (contextValue !== block.value) return null;
return <>{renderBlocks(block.blocks, context)}</>;
}
/**
* Conditionele afbeelding - toont alleen als OS matcht.
*/
function ConditionalImageBlock({ block, context }) {
const contextValue = getContextValue(block.key, context);
if (contextValue !== block.value) return null;
return (
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }}
/>
);
}
/**
* Probleemoplossing kaart.
*/
function TroubleshootBlock({ title, content }) {
return (
<div className="card mb-4">
<p className="font-semibold text-warm-900 mb-2">{title}</p>
<div
className="prose-installatie"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
</div>
);
}