/** * directives.js - Custom directive parser voor Markdown * * Parseert custom syntax zoals :::tabs, :::warning, :::accordion etc. * en splitst het document in typed blocks die de renderer kan verwerken. * * Ondersteunde directives: * - :::tabs{os} OS-tabbladen (Mac/Windows) * - ::tab[Label] Tab binnen een tabs-blok * - :::accordion[T] Uitklapbare sectie * - :::warning Waarschuwingsblok (teal) * - :::info Informatieblok (warm) * - :::steps Genummerde stappen met iconen * - :::checklist Checklijst met vinkjes * - :::checklist-verify Verificatie checklijst * - :::command[cmd] Terminal commando met uitleg * - :::if{os=value} Conditionele content * - :::image{os=val} Conditionele afbeelding * - :::troubleshoot[T] Probleemoplossing kaart */ /** * Hoofdfunctie: parseert Markdown met directives naar een block-array. * * @param {string} markdown - Raw Markdown tekst met directives * @returns {{ frontmatter: object, blocks: Array }} Geparsed resultaat */ export function parseDocument(markdown) { const { frontmatter, body } = extractFrontmatter(markdown); const blocks = parseDirectives(body); return { frontmatter, blocks }; } /** * Extraheert YAML frontmatter (---) uit de Markdown. */ function extractFrontmatter(markdown) { const match = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { frontmatter: {}, body: markdown }; const frontmatter = {}; match[1].split('\n').forEach(line => { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); frontmatter[key] = value; } }); return { frontmatter, body: match[2] }; } /** * Parseert de body-tekst in een array van typed blocks. * Werkt als een line-by-line state machine. */ function parseDirectives(text) { const lines = text.split('\n'); const blocks = []; let i = 0; while (i < lines.length) { const line = lines[i]; // :::tabs{id} const tabsMatch = line.match(/^:::tabs\{(\w+)\}/); if (tabsMatch) { const result = parseTabs(lines, i, tabsMatch[1]); blocks.push(result.block); i = result.nextIndex; continue; } // :::accordion[titel] const accordionMatch = line.match(/^:::accordion\[(.+?)\]/); if (accordionMatch) { const result = parseEnclosed(lines, i); blocks.push({ type: 'accordion', title: accordionMatch[1], blocks: parseDirectives(result.content), }); i = result.nextIndex; continue; } // :::warning if (line.trim() === ':::warning') { const result = parseEnclosed(lines, i); blocks.push({ type: 'warning', content: result.content }); i = result.nextIndex; continue; } // :::info if (line.trim() === ':::info') { const result = parseEnclosed(lines, i); blocks.push({ type: 'info', content: result.content }); i = result.nextIndex; continue; } // :::tip if (line.trim() === ':::tip') { const result = parseEnclosed(lines, i); blocks.push({ type: 'tip', content: result.content }); i = result.nextIndex; continue; } // :::steps if (line.trim() === ':::steps') { const result = parseEnclosed(lines, i); blocks.push({ type: 'steps', steps: parseStepItems(result.content) }); i = result.nextIndex; continue; } // :::checklist if (line.trim() === ':::checklist') { const result = parseEnclosed(lines, i); blocks.push({ type: 'checklist', content: result.content }); i = result.nextIndex; continue; } // :::checklist-verify if (line.trim() === ':::checklist-verify') { const result = parseEnclosed(lines, i); blocks.push({ type: 'checklist-verify', content: result.content }); i = result.nextIndex; continue; } // :::command[cmd] const commandMatch = line.match(/^:::command\[(.+?)\]/); if (commandMatch) { const result = parseEnclosed(lines, i); blocks.push({ type: 'command', command: commandMatch[1], content: result.content, }); i = result.nextIndex; continue; } // :::if{os=value} const ifMatch = line.match(/^:::if\{(\w+)=(\w+)\}/); if (ifMatch) { const result = parseEnclosed(lines, i); blocks.push({ type: 'conditional', key: ifMatch[1], value: ifMatch[2], blocks: parseDirectives(result.content), }); i = result.nextIndex; continue; } // :::image{os=value} const imageIfMatch = line.match(/^:::image\{(\w+)=(\w+)\}/); if (imageIfMatch) { const result = parseEnclosed(lines, i); blocks.push({ type: 'conditional-image', key: imageIfMatch[1], value: imageIfMatch[2], content: result.content, }); i = result.nextIndex; continue; } // :::troubleshoot[titel] const troubleshootMatch = line.match(/^:::troubleshoot\[(.+?)\]/); if (troubleshootMatch) { const result = parseEnclosed(lines, i); blocks.push({ type: 'troubleshoot', title: troubleshootMatch[1], content: result.content, }); i = result.nextIndex; continue; } // Onbekende ::: regel? Behandel als gewone tekst en ga verder. // Dit voorkomt een infinite loop bij onvolledige directives (bijv. tijdens typen). if (line.match(/^:::/)) { blocks.push({ type: 'markdown', content: line }); i++; continue; } // Gewone Markdown regel - verzamel tot de volgende directive const markdownLines = []; while (i < lines.length && !lines[i].match(/^:::/)) { markdownLines.push(lines[i]); i++; } const content = markdownLines.join('\n').trim(); if (content) { blocks.push({ type: 'markdown', content }); } continue; } return blocks; } /** * Parseert een enclosed blok (:::open ... :::close). * Houdt rekening met geneste directives door een depth counter. */ function parseEnclosed(lines, startIndex) { let depth = 1; let i = startIndex + 1; const contentLines = []; while (i < lines.length && depth > 0) { const line = lines[i]; // Sluitende ::: if (line.trim() === ':::') { depth--; if (depth === 0) { i++; break; } contentLines.push(line); i++; continue; } // Openende ::: (genest) if (line.match(/^:::[a-z]/)) { depth++; } contentLines.push(line); i++; } return { content: contentLines.join('\n').trim(), nextIndex: i, }; } /** * Parseert een :::tabs{id} blok met ::tab[Label] sub-tabs. */ function parseTabs(lines, startIndex, tabId) { let depth = 1; let i = startIndex + 1; const tabs = []; let currentTab = null; let currentLines = []; while (i < lines.length && depth > 0) { const line = lines[i]; // Sluitende ::: if (line.trim() === ':::') { depth--; if (depth === 0) { // Sla laatste tab op if (currentTab) { tabs.push({ label: currentTab, blocks: parseDirectives(currentLines.join('\n').trim()), }); } i++; break; } currentLines.push(line); i++; continue; } // Nieuwe tab (depth 1) const tabMatch = depth === 1 && line.match(/^::tab\[(.+?)\]/); if (tabMatch) { // Sla vorige tab op if (currentTab) { tabs.push({ label: currentTab, blocks: parseDirectives(currentLines.join('\n').trim()), }); } currentTab = tabMatch[1]; currentLines = []; i++; continue; } // Geneste opening if (line.match(/^:::[a-z]/)) { depth++; } currentLines.push(line); i++; } return { block: { type: 'tabs', id: tabId, tabs }, nextIndex: i, }; } /** * Parseert de inhoud van een :::steps blok in individuele stappen. * Elke stap heeft een nummer, eerste-regel tekst, en geneste blocks. * * Voorbeeld input: * 1. **Open de app** -- Beschrijving * :::image{os=mac} * ![alt](pad.png) * ::: * 2. Volgende stap * * Resultaat: [{ number: '1', text: '**Open de app** -- Beschrijving', blocks: [...] }, ...] */ function parseStepItems(content) { const lines = content.split('\n'); const steps = []; let currentStep = null; let currentLines = []; for (const line of lines) { const stepMatch = line.match(/^(\d+)\.\s+(.*)/); if (stepMatch) { // Sla de vorige stap op if (currentStep) { steps.push({ number: currentStep.number, text: currentStep.text, blocks: parseDirectives(currentLines.join('\n').trim()), }); } currentStep = { number: stepMatch[1], text: stepMatch[2] }; currentLines = []; } else if (currentStep) { currentLines.push(line); } } // Sla de laatste stap op if (currentStep) { steps.push({ number: currentStep.number, text: currentStep.text, blocks: parseDirectives(currentLines.join('\n').trim()), }); } return steps; }