import fs from "node:fs/promises"; import path from "node:path"; import crypto from "node:crypto"; const SUPPORTED_BLOCK_TYPES = new Set([ "text", "heading", "quote", "table", "image", "multiple-choice", "multiple-response", "open-question", "matching-pairs", ]); const MAX_QUESTION_LENGTH = 350; const MAX_FEEDBACK_LENGTH = 999; const MAX_MULTI_RESPONSE_ANSWERS = 10; const MAX_TABLE_COLUMNS = 5; const MAX_TABLE_ROWS = 20; function makeId() { return crypto.randomUUID(); } function stripQuotes(value) { return value.replace(/^['"]|['"]$/g, "").trim(); } function parseFrontmatter(lines) { const meta = {}; if (lines[0]?.trim() !== "---") { return { meta, nextIndex: 0 }; } let i = 1; for (; i < lines.length; i += 1) { const line = lines[i].trim(); if (line === "---") { return { meta, nextIndex: i + 1 }; } if (!line) { continue; } const separator = line.indexOf(":"); if (separator === -1) { continue; } const key = line.slice(0, separator).trim(); const value = stripQuotes(line.slice(separator + 1).trim()); meta[key] = value; } return { meta, nextIndex: i }; } function parseField(line) { const match = line.match(/^\*\*(.+?):\*\*\s*(.*)$/); if (!match) { return null; } return { key: match[1].trim().toLowerCase(), value: match[2].trim(), }; } function markdownInlineToHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1"); } function markdownTextToHtml(lines) { const html = []; let paragraph = []; let listType = null; let listItems = []; const flushParagraph = () => { if (paragraph.length > 0) { html.push(`

${paragraph.map(markdownInlineToHtml).join("
")}

`); paragraph = []; } }; const flushList = () => { if (listItems.length === 0) { return; } const tag = listType === "ol" ? "ol" : "ul"; html.push( `<${tag}>${listItems .map((item) => `
  • ${markdownInlineToHtml(item)}
  • `) .join("")}` ); listItems = []; listType = null; }; for (const rawLine of lines) { const line = rawLine.trimEnd(); const trimmed = line.trim(); if (!trimmed) { flushParagraph(); flushList(); continue; } const bulletMatch = trimmed.match(/^[-*]\s+(.*)$/); const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/); if (bulletMatch) { flushParagraph(); if (listType && listType !== "ul") { flushList(); } listType = "ul"; listItems.push(bulletMatch[1]); continue; } if (orderedMatch) { flushParagraph(); if (listType && listType !== "ol") { flushList(); } listType = "ol"; listItems.push(orderedMatch[1]); continue; } flushList(); paragraph.push(trimmed); } flushParagraph(); flushList(); return html.join(""); } function htmlToPlainText(html) { return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); } function readSectionLines(lines, startIndex) { const sectionLines = []; let i = startIndex; for (; i < lines.length; i += 1) { if (/^(#|##|###)\s+/.test(lines[i])) { break; } sectionLines.push(lines[i]); } return { sectionLines, nextIndex: i }; } function parseTable(lines) { const fields = Object.fromEntries( lines .map((line) => parseField(line.trim())) .filter(Boolean) .map((field) => [field.key, field.value]) ); const tableLines = lines.filter((line) => /^\|.*\|$/.test(line.trim())); const rows = tableLines .filter((line) => !/^\|(?:\s*:?-+:?\s*\|)+$/.test(line.trim())) .map((line) => line .trim() .slice(1, -1) .split("|") .map((cell) => cell.trim()) ); return { id: makeId(), type: "table", data: { ...(fields.titel ? { title: fields.titel } : {}), topHeader: fields["top header"]?.toLowerCase() === "aan" || fields["top header"]?.toLowerCase() === "true" || fields["bovenste header"]?.toLowerCase() === "aan" || fields["bovenste header"]?.toLowerCase() === "true", leftHeader: fields["left header"]?.toLowerCase() === "aan" || fields["left header"]?.toLowerCase() === "true" || fields["linker header"]?.toLowerCase() === "aan" || fields["linker header"]?.toLowerCase() === "true", rows, }, }; } function parseImage(lines) { const fields = Object.fromEntries( lines .map((line) => parseField(line.trim())) .filter(Boolean) .map((field) => [field.key, field.value]) ); return { id: makeId(), type: "image", data: { ...(fields.titel ? { title: fields.titel } : {}), ...(fields.onderschrift ? { caption: fields.onderschrift } : {}), ...(fields.alt ? { alt: fields.alt } : {}), ...(fields.bestand ? { sourcePath: fields.bestand } : {}), }, }; } function parseQuestionBlock(lines, type) { const answers = []; const pairs = []; let question = ""; let feedback = ""; for (const rawLine of lines) { const trimmed = rawLine.trim(); if (!trimmed) { continue; } const field = parseField(trimmed); if (field) { if (field.key === "vraag" || field.key === "instructie") { question = field.value; } else if (field.key === "toelichting") { feedback = field.value; } continue; } const answerMatch = trimmed.match(/^-\s+\[([ xX])\]\s+(.*)$/); if (answerMatch) { answers.push({ id: makeId(), text: answerMatch[2].trim(), ...(answerMatch[1].toLowerCase() === "x" ? { correct: true } : {}), }); continue; } const pairMatch = trimmed.match(/^-\s+(.*?)\s*=\s*(.*)$/); if (pairMatch) { pairs.push({ id: makeId(), left: pairMatch[1].trim(), right: pairMatch[2].trim(), }); } } if (type === "matching-pairs") { return { id: makeId(), type, data: { question, pairs, ...(feedback ? { feedback: `

    ${markdownInlineToHtml(feedback)}

    ` } : {}), }, }; } return { id: makeId(), type, data: { question, ...(answers.length > 0 ? { answers } : {}), ...(feedback ? { feedback: `

    ${markdownInlineToHtml(feedback)}

    ` } : {}), }, }; } function parseBlock(blockType, sectionLines) { const trimmedLines = sectionLines.map((line) => line.trimEnd()); switch (blockType) { case "Tekst": { const titleField = trimmedLines .map((line) => parseField(line.trim())) .find((field) => field?.key === "titel"); const contentLines = titleField ? trimmedLines.filter((line) => parseField(line.trim()) === null) : trimmedLines; return { id: makeId(), type: "text", data: { ...(titleField?.value ? { title: titleField.value } : {}), content: markdownTextToHtml(contentLines), }, }; } case "Kop": return { id: makeId(), type: "heading", data: { title: trimmedLines.filter(Boolean).join(" ").trim(), }, }; case "Quote": { const fields = Object.fromEntries( trimmedLines .map((line) => parseField(line.trim())) .filter(Boolean) .map((field) => [field.key, field.value]) ); return { id: makeId(), type: "quote", data: { quote: fields.tekst ?? "", ...(fields.auteur ? { author: fields.auteur } : {}), }, }; } case "Tabel": return parseTable(trimmedLines); case "Afbeelding": return parseImage(trimmedLines); case "Meerkeuze": return parseQuestionBlock(trimmedLines, "multiple-choice"); case "Multi antwoord": return parseQuestionBlock(trimmedLines, "multiple-response"); case "Open vraag": return parseQuestionBlock(trimmedLines, "open-question"); case "Koppelvraag": return parseQuestionBlock(trimmedLines, "matching-pairs"); default: throw new Error(`Onbekend bloktype: ${blockType}`); } } export function parseMarkdownToOnlineAcademy(markdown) { const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const { meta, nextIndex } = parseFrontmatter(lines); const documentModel = { training: meta.training ?? "", chapters: [], examPage: { enabled: false, blocks: [], examSettings: { showFeedbackAfterExam: false, questionPool: false, numberOfQuestions: 0, minimumPassingScore: 60, }, }, }; let currentChapter = null; let currentPage = null; let i = nextIndex; while (i < lines.length) { const line = lines[i].trim(); if (!line) { i += 1; continue; } if (line.startsWith("# Module:")) { currentChapter = { id: makeId(), title: line.replace("# Module:", "").trim(), pages: [], }; documentModel.chapters.push(currentChapter); currentPage = null; i += 1; continue; } if (line.startsWith("## Pagina:")) { if (!currentChapter) { throw new Error("Pagina gevonden voordat een module/hoofdstuk is gestart."); } currentPage = { id: makeId(), pageType: "number", title: line.replace("## Pagina:", "").trim(), blocks: [], }; currentChapter.pages.push(currentPage); i += 1; continue; } if (line.startsWith("### ")) { if (!currentPage) { throw new Error("Blok gevonden voordat een pagina is gestart."); } const blockType = line.replace("### ", "").trim(); const { sectionLines, nextIndex: afterSection } = readSectionLines(lines, i + 1); currentPage.blocks.push(parseBlock(blockType, sectionLines)); i = afterSection; continue; } i += 1; } return { title: documentModel.training, jsonContent: JSON.stringify({ chapters: documentModel.chapters, examPage: documentModel.examPage, }), }; } export function parseMarkdownToCourseModel(markdown) { const result = parseMarkdownToOnlineAcademy(markdown); return { title: result.title, ...JSON.parse(result.jsonContent), }; } export function validateCourseModel(courseModel, supportedTypes = SUPPORTED_BLOCK_TYPES) { const issues = []; for (const chapter of courseModel.chapters) { for (const page of chapter.pages) { for (const block of page.blocks) { if (!supportedTypes.has(block.type)) { issues.push( `Bloktype "${block.type}" op pagina "${page.title}" wordt nog niet ondersteund.` ); } if (block.type === "image" && !block.data.sourcePath?.trim()) { issues.push(`Afbeeldingsblok op pagina "${page.title}" mist een Bestand-pad.`); } if (block.type === "table") { const rows = block.data.rows ?? []; const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); if (rows.length === 0) { issues.push(`Tabel op pagina "${page.title}" bevat geen rijen.`); } if (rows.length > MAX_TABLE_ROWS) { issues.push( `Tabel op pagina "${page.title}" mag maximaal ${MAX_TABLE_ROWS} rijen hebben.` ); } if (columnCount === 0) { issues.push(`Tabel op pagina "${page.title}" bevat geen kolommen.`); } if (columnCount > MAX_TABLE_COLUMNS) { issues.push( `Tabel op pagina "${page.title}" mag maximaal ${MAX_TABLE_COLUMNS} kolommen hebben.` ); } } if ( block.type === "multiple-choice" || block.type === "multiple-response" || block.type === "open-question" ) { if (!block.data.question?.trim()) { issues.push(`Vraag ontbreekt voor bloktype "${block.type}" op pagina "${page.title}".`); } if ((block.data.question?.trim().length ?? 0) > MAX_QUESTION_LENGTH) { issues.push( `Vraag voor bloktype "${block.type}" op pagina "${page.title}" is langer dan ${MAX_QUESTION_LENGTH} karakters.` ); } if ( block.data.feedback && htmlToPlainText(block.data.feedback).length > MAX_FEEDBACK_LENGTH ) { issues.push( `Toelichting voor bloktype "${block.type}" op pagina "${page.title}" is langer dan ${MAX_FEEDBACK_LENGTH} karakters.` ); } } if (block.type === "multiple-choice") { const answers = block.data.answers ?? []; const correctCount = answers.filter((answer) => answer.correct).length; if (answers.length < 2) { issues.push( `Meerkeuzevraag op pagina "${page.title}" moet minimaal 2 antwoorden hebben.` ); } if (correctCount !== 1) { issues.push( `Meerkeuzevraag op pagina "${page.title}" moet exact 1 correct antwoord hebben.` ); } } if (block.type === "multiple-response") { const answers = block.data.answers ?? []; const correctCount = answers.filter((answer) => answer.correct).length; if (answers.length < 2) { issues.push( `Multi-antwoordvraag op pagina "${page.title}" moet minimaal 2 antwoorden hebben.` ); } if (answers.length > MAX_MULTI_RESPONSE_ANSWERS) { issues.push( `Multi-antwoordvraag op pagina "${page.title}" mag maximaal ${MAX_MULTI_RESPONSE_ANSWERS} antwoorden hebben.` ); } if (correctCount < 1) { issues.push( `Multi-antwoordvraag op pagina "${page.title}" moet minimaal 1 correct antwoord hebben.` ); } } } } } return issues; } async function main() { const inputPath = process.argv[2]; const outputPath = process.argv[3]; if (!inputPath) { throw new Error( "Gebruik: node markdown-to-onlineacademy-json.mjs [output.json]" ); } const markdown = await fs.readFile(inputPath, "utf8"); const result = parseMarkdownToOnlineAcademy(markdown); const resolvedOutput = outputPath ?? path.join(path.dirname(inputPath), `${path.basename(inputPath, path.extname(inputPath))}.onlineacademy.json`); await fs.writeFile(resolvedOutput, JSON.stringify(result, null, 2), "utf8"); console.log(`Dry-run JSON geschreven naar ${resolvedOutput}`); } if (import.meta.url === `file://${process.argv[1]}`) { await main(); }