workshopclaudecode/src/lib/markdown/directives.js

356 lines
8.9 KiB
JavaScript
Raw Normal View History

/**
* 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;
}
// :::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;
}