2026-03-29 19:44:03 +00:00
|
|
|
/* 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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-02 13:25:37 +00:00
|
|
|
import { useState, useCallback } from 'react';
|
2026-03-29 19:44:03 +00:00
|
|
|
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} />;
|
2026-04-02 13:25:37 +00:00
|
|
|
case 'tip':
|
|
|
|
|
return <TipBlock content={block.content} />;
|
2026-03-29 19:44:03 +00:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 13:25:37 +00:00
|
|
|
/**
|
|
|
|
|
* 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 19:44:03 +00:00
|
|
|
/**
|
|
|
|
|
* 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-02 13:25:37 +00:00
|
|
|
* Terminal commando blok (donkere achtergrond) met kopieerknop.
|
2026-03-29 19:44:03 +00:00
|
|
|
*/
|
|
|
|
|
function CommandBlock({ command, content }) {
|
2026-04-02 13:25:37 +00:00
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleCopy = useCallback(() => {
|
|
|
|
|
navigator.clipboard.writeText(content).then(() => {
|
|
|
|
|
setCopied(true);
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
});
|
|
|
|
|
}, [content]);
|
|
|
|
|
|
2026-03-29 19:44:03 +00:00
|
|
|
return (
|
2026-04-02 13:25:37 +00:00
|
|
|
<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>
|
2026-03-29 19:44:03 +00:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|