2026-03-29 19:44:03 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 13:25:37 +00:00
|
|
|
// :::tip
|
|
|
|
|
if (line.trim() === ':::tip') {
|
|
|
|
|
const result = parseEnclosed(lines, i);
|
|
|
|
|
blocks.push({ type: 'tip', content: result.content });
|
|
|
|
|
i = result.nextIndex;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 19:44:03 +00:00
|
|
|
// :::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}
|
|
|
|
|
* 
|
|
|
|
|
* :::
|
|
|
|
|
* 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;
|
|
|
|
|
}
|